diff --git a/composer.json b/composer.json index 4aa66c5..f57d8e7 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "lean" : { "autoload": { "definitions" : [ - "meta/definitions/software.php" + "meta/definitions/software.php", + "meta/definitions/storage.php", ], "config": [ "meta/config.php" diff --git a/meta/config.php b/meta/config.php index d46d5fc..a723c71 100644 --- a/meta/config.php +++ b/meta/config.php @@ -1,9 +1,28 @@ <?php +$path = dirname(__DIR__, 1); + return [ 'lean' => [ 'autoload' => [ 'lean.api', ] ], -]; + + 'ulmus' => [ + 'connections' => [ + 'lean.api' => [ + 'adapter' => getenv("LEAN_API_ADAPTER") ?: "SQLite", + 'path' => getenv('PROJECT_PATH') . DIRECTORY_SEPARATOR . ( getenv("LEAN_API_PATH") ?: "var/lean-api.sqlite3" ), + 'pragma_begin' => array_merge( + explode(',', getenv("LEAN_API_PRAGMA_BEGIN") ?: "foreign_keys=ON,synchronous=NORMAL"), + explode(',', getenv('DEBUG') ? getenv("LEAN_API_PRAGMA_DEBUG_BEGIN") : "journal_mode=WAL") + ), + 'pragma_close' => array_merge( + explode(',', getenv("LEAN_API_PRAGMA_CLOSE") ?: "analysis_limit=500,optimize"), + explode(',', getenv('DEBUG') ? getenv("LEAN_API_PRAGMA_DEBUG_CLOSE") : "") + ), + ], + ] + ] +]; \ No newline at end of file diff --git a/meta/definitions/software.php b/meta/definitions/software.php index f90df3d..497042d 100644 --- a/meta/definitions/software.php +++ b/meta/definitions/software.php @@ -1,6 +1,8 @@ <?php -$path = dirname(__DIR__, 2); +use Ulmus\ConnectionAdapter; + +putenv('LEAN_API_PROJECT_PATH=' . $path = dirname(__DIR__, 2)); return [ 'lean.api' => [ @@ -22,6 +24,13 @@ return [ ], ], + 'ulmus' => [ + 'entities' => [ 'Lean\\Api\\Entity' => implode(DIRECTORY_SEPARATOR, [ $path, 'src', 'Entity', '' ]) ], + 'adapters' => [ + DI\get('lean.api:storage'), + ] + ], + 'tell' => [ 'json' => [ [ @@ -31,10 +40,19 @@ return [ ] ], - /*'routes' => [ + 'routes' => [ 'Lean\\Api\\Controller' => implode(DIRECTORY_SEPARATOR, [ $path, "src", "Controller", "" ]), - ],*/ + ], ], + 'lean.api:storage' => function($c) { + $adapter = new ConnectionAdapter('lean.api', $c->get('config')['ulmus'], false); + $adapter->resolveConfiguration(); + + return $adapter; + }, + \Lean\Api\Factory\MessageFactoryInterface::class => DI\autowire(\Lean\Api\Lib\Message::class), + \League\Route\Strategy\ApplicationStrategy::class => DI\autowire(\Lean\Api\ApplicationStrategy::class), + \Lean\Api\Factory\DebugFormFactoryInterface::class => DI\autowire(\Lean\Api\Factory\DebugFormFactory::class), ]; \ No newline at end of file diff --git a/meta/definitions/storage.php b/meta/definitions/storage.php new file mode 100644 index 0000000..59ef76c --- /dev/null +++ b/meta/definitions/storage.php @@ -0,0 +1,19 @@ +<?php + +use Psr\Container\ContainerInterface; + +use Ulmus\ConnectionAdapter, + Ulmus\Container\AdapterProxy; + +use Storage\Session; + +use function DI\autowire, DI\create, DI\get; + +return [ + 'lean.api:storage' => function($c) { + $adapter = new ConnectionAdapter('lean.api', $c->get('config')['ulmus'], false); + $adapter->resolveConfiguration(); + + return $adapter; + }, +]; diff --git a/meta/docs/debug.md b/meta/docs/debug.md new file mode 100644 index 0000000..f127101 --- /dev/null +++ b/meta/docs/debug.md @@ -0,0 +1,21 @@ +# LEAN API + +## Debug tooling to help development within Lean's API + +[Documentation](../) / Debug API queries + +-------------------------------------------------------------------------------- + +### Routes + +{route:descriptor} + +{request:debugger} + +### Forms / actions + +{form:descriptor} + +### Entities + +{entity:descriptor} \ No newline at end of file diff --git a/meta/docs/debug/errors.md b/meta/docs/debug/errors.md new file mode 100644 index 0000000..9d2f81a --- /dev/null +++ b/meta/docs/debug/errors.md @@ -0,0 +1,21 @@ +# LEAN API + +## Debug tooling to help development within Lean's API + +[Lean API](../../debug) / Debug API queries + +-------------------------------------------------------------------------------- + +### Routes + +{route:descriptor} + +{request:debugger} + +### Forms / actions + +{form:descriptor} + +### Entities + +{entity:descriptor} \ No newline at end of file diff --git a/meta/docs/lean-api.md b/meta/docs/lean-api.md new file mode 100644 index 0000000..6877c23 --- /dev/null +++ b/meta/docs/lean-api.md @@ -0,0 +1,7 @@ +# Lean API + +## Lean's API internal debug API + +### Routes disponibles + +- [Debug / Errors](./debug/errors/documentation) diff --git a/meta/i18n/fr/lean.api.json b/meta/i18n/fr/lean.api.json index 857bd49..01ebd76 100644 --- a/meta/i18n/fr/lean.api.json +++ b/meta/i18n/fr/lean.api.json @@ -12,11 +12,11 @@ }, "form": { "error": { - "entity": "Une propriété $entity est nécessaire dans ce form" + "entity": "Une propriété $entity est nécessaire pour envoyer ce formulaire." }, "save": { "error": { - "save": "Une erreur est survenue en tenant de sauvegarder les données", + "entity": "Une erreur est survenue en tenant de sauvegarder les données pour l'entité {$entity}.", "pdo": "Une erreur est survenue : '{$error}'" }, "success": { diff --git a/src/ApplicationStrategy.php b/src/ApplicationStrategy.php new file mode 100644 index 0000000..33aeca1 --- /dev/null +++ b/src/ApplicationStrategy.php @@ -0,0 +1,72 @@ +<?php + +namespace Lean\Api; + +use League\Route\Strategy; + +use League\Route\Http\Exception\NotFoundException; +use Lean\Factory\HttpFactoryInterface; +use Picea\Asset\Asset; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Picea\Picea; +use Psr\Http\Server\RequestHandlerInterface; +use function DI\get; + +class ApplicationStrategy extends Strategy\ApplicationStrategy { + + public function __construct( + protected HttpFactoryInterface $httpFactory, + ContainerInterface $di, + ) { + $this->container = $di; + } + + public function getThrowableHandler(): MiddlewareInterface + { + return new class implements MiddlewareInterface + { + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + try { + return $handler->handle($request); + } catch (\Throwable $ex) { + echo sprintf("%s in <strong>%s</strong> <pre>%s</pre>", $ex->getMessage(), $ex->getFile() . ":" . $ex->getLine(), $ex->getTraceAsString()); + exit(); + } + } + }; + } + + public function getNotFoundDecorator(NotFoundException $exception): MiddlewareInterface + { + return new class($this->httpFactory) implements MiddlewareInterface { + + public function __construct( + protected HttpFactoryInterface $httpFactory, + ) {} + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (php_sapi_name() !== 'cli' and ! defined('STDIN')) { + return $this->throw404($request); + } + + return $handler->handle($request); + } + + public function throw404(ServerRequestInterface $request) : ResponseInterface + { + return $this->httpFactory->createJsonResponse([ + 'status' => 'error', + 'message' => "Not Found", + 'ts' => time(), + ], 404); + } + }; + } +} \ No newline at end of file diff --git a/src/Attribute/ContextField.php b/src/Attribute/ContextField.php index 0b04e15..1e67e25 100644 --- a/src/Attribute/ContextField.php +++ b/src/Attribute/ContextField.php @@ -12,5 +12,6 @@ class ContextField public ?int $maxLength = null, public ?string $regexFormat = null, public ?string $example = null, + public bool $hidden = false, ) {} } \ No newline at end of file diff --git a/src/Controller/Debug.php b/src/Controller/Debug.php new file mode 100644 index 0000000..8b2c538 --- /dev/null +++ b/src/Controller/Debug.php @@ -0,0 +1,23 @@ +<?php + +namespace Lean\Api\Controller; + +use Lean\Api\{Controller, Entity, Form, Lib}; +use Notes\Route\Attribute\Method\Route; +use Notes\Route\Attribute\Object\Route as RouteObj; +use Notes\Security\Attribute\Security; +use Notes\Security\Attribute\Taxus; +use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; + +#[Security(locked: false)] +#[RouteObj(base: "/lean/debug")] +class Debug { + + use \Lean\Api\Lib\ControllerTrait; + + #[Route("/", name: "lean.api:debug-doc", method: "GET", description: "Documentation section")] + public function documentation(ServerRequestInterface $request, array $arguments) : ResponseInterface + { + return $this->renderMarkdown(getenv("LEAN_API_PROJECT_PATH") . "/meta/docs/lean-api.md", [ Entity\Error::class, ], [ Form\Debug\ErrorContext::class ]); + } +} diff --git a/src/Controller/Debug/Errors.php b/src/Controller/Debug/Errors.php new file mode 100644 index 0000000..75e6a7c --- /dev/null +++ b/src/Controller/Debug/Errors.php @@ -0,0 +1,46 @@ +<?php + +namespace Lean\Api\Controller\Debug; + +use Lean\Api\{Controller, Entity, Form, Lib}; +use Notes\Route\Attribute\Method\Route; +use Notes\Route\Attribute\Object\Route as RouteObj; +use Notes\Security\Attribute\Security; +use Notes\Security\Attribute\Taxus; +use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; + +#[Security(locked: false)] +## [Taxus(Entity\UserTypeEnum::Developer)] +#[RouteObj(base: "/lean/debug/errors")] +class Errors { + + use \Lean\Api\Lib\ControllerTrait; + + #[Route("/documentation", name: "lean.api:errors-doc", method: "GET", description: "Documentation section")] + public function documentation(ServerRequestInterface $request, array $arguments) : ResponseInterface + { + return $this->renderMarkdown(getenv("LEAN_API_PROJECT_PATH") . "/meta/docs/debug/errors.md", [ Entity\Error::class, ], [ Form\Debug\ErrorContext::class ]); + } + + #[Route("/", name: "lean.api:errors-list", method: [ "GET" ], description: "List every errors received")] + public function list(ServerRequestInterface $request, array $arguments): ResponseInterface + { + $search = Entity\Error::searchRequest()->fromRequest($request); + $collection = Entity\Error::repository()->filterServerRequest($search)->loadAll()->iterate(function($e) { + unset($e->trace); + }); + + return $this->output($collection, $search->count); + } + + #[Route("/{id:\d+}", name: "lean.api:errors-single", method: [ "GET" ], description: "Get a single error from it's ID")] + public function single(ServerRequestInterface $request, array $arguments): ResponseInterface + { + $type = Entity\Error::class; + $request = $this->searchEntityFromRequest($request, $type); + $result = $request->getAttribute('lean.searchRequest'); + + return $this->output($result[$type]->entity, $result[$type]->search->count); + } + +} diff --git a/src/Entity/Error.php b/src/Entity/Error.php new file mode 100644 index 0000000..2dc2468 --- /dev/null +++ b/src/Entity/Error.php @@ -0,0 +1,79 @@ +<?php + +namespace Lean\Api\Entity; + +use Lean\Api\Attribute\EntityField; +use Ulmus\EntityTrait, + Ulmus\SearchRequest\SearchableInterface, + Ulmus\SearchRequest\SearchRequestInterface, + Ulmus\Entity\Field\Datetime; + +use Ulmus\{Attribute\Obj\Table, + Entity\EntityInterface}; + +use Ulmus\{Api\Common\MethodEnum, + SearchRequest, + SearchRequest\Attribute\PropertyValueModifier\Split, + SearchRequest\Attribute\PropertyValueSource, + SearchRequest\Attribute\SearchOrderBy, + SearchRequest\Attribute\SearchWhere, + SearchRequest\SearchMethodEnum}; + +use Ulmus\Attribute\Property\{Field, Filter, FilterJoin, Relation, Join, Virtual, Where}; + +#[Table(name: "debug_errors", adapter: "lean.api")] +class Error implements EntityInterface, \JsonSerializable { + use EntityTrait; + + #[Field\Id] + public int $id; + + #[Field] + #[EntityField(description: "URL from which the error occurred")] + public string $url; + + #[Field] + #[EntityField(description: "HTTP method", field: "httpMethod")] + public MethodEnum $method; + + #[Field] + #[EntityField(description: "Error message")] + public string $message; + + #[Field] + #[EntityField(description: "Error code")] + public int $code; + + #[Field] + #[EntityField(description: "File from which the error occurred")] + public string $file; + + #[Field] + #[EntityField(description: "Line in the file from which the error occurred")] + public int $line; + + #[Field] + #[EntityField(description: "Stack trace of the exception")] + public array $trace; + + #[Field\CreatedAt(name: "created_at")] + public Datetime $createdAt; + + public function __toString() { + return mb_strlen($this->message) > 255 ? mb_substr($this->message, 0, 255) . "[...]" : $this->message; + } + + public static function searchRequest(...$arguments) : SearchRequestInterface + { + return new #[SearchRequest\Attribute\SearchRequestParameter(Error::class)] class(...$arguments) extends SearchRequest\SearchRequest { + #[SearchRequest\Attribute\SearchWhere(source: SearchRequest\Attribute\PropertyValueSource::RequestAttribute)] + public int $id; + + #[SearchWhere(method: SearchMethodEnum::Like)] + public string $message; + + #[SearchOrderBy(parameter: "created_at",)] + public string $createdAt = SearchOrderBy::DESCENDING; + }; + } +} \ No newline at end of file diff --git a/src/Entity/User/UserPrivilegeInterface.php b/src/Entity/User/UserPrivilegeInterface.php new file mode 100644 index 0000000..6cf34a2 --- /dev/null +++ b/src/Entity/User/UserPrivilegeInterface.php @@ -0,0 +1,11 @@ +<?php + +namespace Lean\Api\Entity\User; + +interface UserPrivilegeInterface +{ + public function isDeveloper() : bool; + public function isAdmin() : bool; + public function isUser() : bool; + public function isAnonymous() : bool; +} \ No newline at end of file diff --git a/src/Factory/DebugFormFactory.php b/src/Factory/DebugFormFactory.php new file mode 100644 index 0000000..463b753 --- /dev/null +++ b/src/Factory/DebugFormFactory.php @@ -0,0 +1,39 @@ +<?php + +namespace Lean\Api\Factory; + +use Lean\Api\{Factory, Form, Entity, Lib}; +use Lean\LanguageHandler; +use Picea\Ui\Method\FormContextInterface; +use Picea\Ui\Method\FormInterface; +use Psr\Http\Message\ServerRequestInterface; +use Ulmus\Entity\EntityInterface; + +class DebugFormFactory implements DebugFormFactoryInterface +{ + public function __construct( + protected ServerRequestInterface $request, + protected LanguageHandler $languageHandler, + protected MessageFactoryInterface $messageFactory, + ) {} + + public function errorSave(EntityInterface|Entity\Error $entity): FormInterface + { + return new Form\Debug\ErrorSave($this->languageHandler, $this->messageFactory, $entity); + } + + public function errorSaveContext(? ServerRequestInterface $request = null, ? string $formName = null): FormContextInterface + { + return new Form\Debug\ErrorContext($request ?: $this->request, $formName); + } + + public function errorDelete(EntityInterface|Entity\Error $entity): FormInterface + { + return new Form\Debug\ErrorDelete($this->languageHandler, $this->messageFactory, $entity); + } + + public function errorDeleteContext(?ServerRequestInterface $request = null, ?string $formName = null): FormContextInterface + { + return new Lib\FormContext($request, $formName); + } +} \ No newline at end of file diff --git a/src/Factory/DebugFormFactoryInterface.php b/src/Factory/DebugFormFactoryInterface.php new file mode 100644 index 0000000..4ac1936 --- /dev/null +++ b/src/Factory/DebugFormFactoryInterface.php @@ -0,0 +1,21 @@ +<?php + +namespace Lean\Api\Factory; + +use Picea\Ui\Method\FormContextInterface; +use Picea\Ui\Method\FormInterface; +use Psr\Http\Message\ServerRequestInterface; +use Lean\LanguageHandler; +use Ulmus\Entity\EntityInterface; +use Lean\Api\Entity; + +interface DebugFormFactoryInterface +{ + public function __construct(ServerRequestInterface $request, LanguageHandler $languageHandler, MessageFactoryInterface $messageFactory,); + + ### ERROR SAVE + public function errorSave(Entity\Error|EntityInterface $entity): FormInterface; + public function errorSaveContext(?ServerRequestInterface $request = null, ? string $formName = null): FormContextInterface; + public function errorDelete(Entity\Error|EntityInterface $entity): FormInterface; + public function errorDeleteContext(?ServerRequestInterface $request = null, ? string $formName = null): FormContextInterface; +} \ No newline at end of file diff --git a/src/Form/Debug/ErrorContext.php b/src/Form/Debug/ErrorContext.php new file mode 100644 index 0000000..80f5f49 --- /dev/null +++ b/src/Form/Debug/ErrorContext.php @@ -0,0 +1,45 @@ +<?php + +namespace Lean\Api\Form\Debug; + +use Lean\Api\{Entity}; +use Lean\Api\Attribute\ContextField; +use Picea\Ui\Method\FormContext; +use Ulmus\Api\Common\MethodEnum; +use Ulmus\Entity\Field\Datetime; + +#[ContextField(description: "This form saves an error received by the API")] +class ErrorContext extends FormContext +{ + #[ContextField(description: "URL which the error was received from", hidden: true, )] + public string $url; + + #[ContextField( description: "HTTP method used when this error was generated", hidden: true, )] + public MethodEnum|string $httpMethod; + + #[ContextField(description: "Error message generated")] + public string $message; + + #[ContextField(description: "HTTP error code")] + public int $code; + + #[ContextField(description: "File in which the error occured")] + public string $file; + + #[ContextField(description: "Line of the error")] + public int $line; + + #[ContextField(description: "Complete stack trace (when available)")] + public array $trace; + + public function valid(? Entity\Error $error = null): bool + { + $server = $this->getRequest()->getServerParams(); + + $this->url = ( ( 'on' === ( $server['HTTPS'] ?? false ) ) ? 'https' : 'http' ) . '://' . $server['HTTP_HOST'] . $server["REQUEST_URI"]; + + $this->httpMethod = MethodEnum::from($server['REQUEST_METHOD']); + + return parent::valid(); + } +} \ No newline at end of file diff --git a/src/Form/Debug/ErrorSave.php b/src/Form/Debug/ErrorSave.php new file mode 100644 index 0000000..03e937c --- /dev/null +++ b/src/Form/Debug/ErrorSave.php @@ -0,0 +1,20 @@ +<?php + +namespace Lean\Api\Form\Debug; + +use Lean\Api\Factory\MessageFactoryInterface; +use Lean\LanguageHandler; +use Lean\Api\{Lib, Entity}; + +class ErrorSave extends \Lean\Api\Form\Save { + + public function __construct( + protected LanguageHandler $languageHandler, + protected MessageFactoryInterface $message, + public Entity\Error $entity, + ) { + parent::__construct($this->languageHandler, $this->message); + + $this->contextClass = ErrorContext::class; + } +} diff --git a/src/Form/Save.php b/src/Form/Save.php index 3bd9eaa..67794d8 100644 --- a/src/Form/Save.php +++ b/src/Form/Save.php @@ -22,17 +22,17 @@ abstract class Save implements \Picea\Ui\Method\FormInterface { return $context->valid($this->getEntity()->isLoaded() ? $this->getEntity() : null); } - public function execute(FormContextInterface $context) : void + public function execute(FormContextInterface $context) : mixed { $entity = $this->getEntity(); - if ($entity->isLoaded() ) { - if (property_exists($entity, 'updatedAt')) { + if ($entity->isLoaded()) { + if (property_exists($entity, 'updatedAt') && $entity->repository()->generateDatasetDiff($entity) ) { $entity->updatedAt = new Datetime(); } } else { - if (property_exists($entity, 'createdAt')) { + if (property_exists($entity, 'createdAt') && empty($entity->createdAt)) { $entity->createdAt = new Datetime(); } } @@ -40,18 +40,22 @@ abstract class Save implements \Picea\Ui\Method\FormInterface { try { $this->assignContextToEntity($context); - if ( $entity::repository()->save($entity) ) { + if ( $saved = $entity::repository()->save($entity) ) { $context->pushMessage($this->message::generateSuccess( $this->lang('lean.api.form.save.success.entity') )); } else { - throw new \InvalidArgumentException($this->lang('lean.api.form.save.error.entity')); + $context->pushMessage($this->message::generateWarning( + $this->lang('lean.api.form.save.error.entity', [ 'entity' => $this->entity::class ]) + )); } } catch(\Throwable $ex) { - throw new \ErrorException($this->lang('lean.api.form.save.error.pdo', [ 'error' => $ex->getMessage() ])); + throw new \PDOException($this->lang('lean.api.form.save.error.pdo', [ 'error' => $ex->getMessage() ])); } + + return $saved; } protected function assignContextToEntity(FormContextInterface $context) : void @@ -60,13 +64,12 @@ abstract class Save implements \Picea\Ui\Method\FormInterface { foreach($entity::resolveEntity()->fieldList() as $key => $property) { $field = $property->getAttribute(Field::class)->object; - if (! $field->readonly || ! $entity->isLoaded()) { $apiField = $property->getAttribute(EntityField::class)->object ?? null; - if ($apiField) { $var = $apiField->field ?: $key; + if ( isset($context->{$var}) ) { if ($apiField->setterMethod) { # Use a setter method diff --git a/src/FormDescriptor.php b/src/FormDescriptor.php index b2306b1..fad6fda 100644 --- a/src/FormDescriptor.php +++ b/src/FormDescriptor.php @@ -15,7 +15,6 @@ class FormDescriptor foreach($forms as $form) { $fields = []; - $propertyHtml = ""; $formName = is_object($form) ? $form::class : $form; $reflector = new ObjectReflection($formName); @@ -24,17 +23,19 @@ class FormDescriptor foreach($reflector->reflectProperties() as $property) { $field = $property->getAttribute(ContextField::class); - if ($field) { + if ($field && ! $field->object->hidden ) { $types = $property->getTypes(); + $fields[] = [ 'name' => $property->name, 'description' => $field->object->description, - 'type' => $field->object->type ?? implode(' | ', array_map(fn($e) => $e->type, $types)), + 'type' => $field->object->type ?? $types, 'allowNulls' => $property->allowsNull(), - 'regexPattern' =>$field->object->regexFormat ?? "aucun", - 'minLength' =>$field->object->minLength ?? "aucune", - 'maxLength' =>$field->object->maxLength ?? "aucune", - 'example' =>$field->object->example, + 'regexPattern' => $field->object->regexFormat ?? null, + 'minLength' => $field->object->minLength ?? null, + 'maxLength' => $field->object->maxLength ?? null, + 'example' => $field->object->example ?? null, + 'values' => $this->generateExampleValues($types), ]; } } @@ -48,4 +49,17 @@ class FormDescriptor return $list; } + + protected function generateExampleValues(array $types) : string|array + { + $values = []; + + foreach($types as $type) { + if ( enum_exists($type->type) ) { + $values = array_merge($values, array_map(fn($e) => $e->value, $type->type::cases())); + } + } + + return $values; + } } \ No newline at end of file diff --git a/src/LeanApiTrait.php b/src/LeanApiTrait.php index 0bf08bb..c0f5100 100644 --- a/src/LeanApiTrait.php +++ b/src/LeanApiTrait.php @@ -2,14 +2,24 @@ namespace Lean\Api; +use DI\Attribute\Inject; use League\CommonMark\CommonMarkConverter; +use Lean\Api\Lib\ApiSearchRequestInterface; use Notes\Attribute\Ignore; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Ulmus\Entity\EntityInterface; +use Ulmus\EntityCollection; +use Ulmus\SearchRequest\SearchRequestInterface; trait LeanApiTrait { use DescriptorTrait; + #[Inject] + protected ContainerInterface $container; + #[Ignore] public function renderMarkdown(string $filepath, array $entities = [], array $forms = [], int $code = 200, array $headers = []) : ResponseInterface { @@ -22,10 +32,15 @@ trait LeanApiTrait if (str_contains($markdown, '{route:descriptor}')) { $markdown = str_replace('{route:descriptor}', $this->renderRawView('lean-api/route_descriptor', [ - 'routes' => (new RouteDescriptor($this, $this->picea->compiler->getExtensionFromToken('url')))->getRoutes() + 'routes' => (new RouteDescriptor($this, $this->picea->compiler->getExtensionFromToken('url'), $this->container))->getRoutes() ]), $markdown); } + if (str_contains($markdown, '{request:debugger}')) + { + $markdown = str_replace('{request:debugger}', $this->renderRawView('lean-api/request_debugger', []), $markdown); + } + if (str_contains($markdown, '{entity:descriptor}')) { $markdown = str_replace('{entity:descriptor}', $this->renderRawView('lean-api/entity_descriptor', [ @@ -44,10 +59,81 @@ trait LeanApiTrait } #[Ignore] - protected function output(\JsonSerializable|array $data, int $count) { + protected function output(\JsonSerializable|array $data, int $count) : ResponseInterface + { return $this->renderJson([ 'data' => $data, 'count' => $count, ]); } + + #[Ignore] + protected function outputSearchResult(ApiSearchRequestInterface $apiSearchRequest) : ResponseInterface + { + return $this->output($apiSearchRequest->getResult(), $apiSearchRequest->getSearch()->count); + } + + #[Ignore] + protected function searchEntityFromRequest(ServerRequestInterface $request, string $entityType, ? string $resultKey = null) : ServerRequestInterface + { + $search = $entityType::searchRequest()->fromRequest($request); + $entity = $entityType::repository()->filterServerRequest($search)->loadOne() ?? false; + + if (! $entity ) { + throw new \InvalidArgumentException(sprintf("L'entré pour l'entité demandé (%s) est introuvable avec le ou les arguments fournis '%s'", $entityType, json_encode($request->getAttributes()))); + } + + return $request->withAttribute("lean.searchRequest", + [ + $resultKey ?: $entityType => new class($search, $entity) implements ApiSearchRequestInterface { + public function __construct( + public readonly SearchRequestInterface $search, + public readonly EntityInterface $entity, + ) {} + + public function getSearch(): SearchRequestInterface + { + return $this->search; + } + + public function getResult(): EntityCollection|EntityInterface + { + return $this->entity; + } + } + ] + $request->getAttribute("lean.searchRequest", []) + ); + } + + #[Ignore] + protected function searchEntitiesFromRequest(ServerRequestInterface $request, string $entityType, ? string $resultKey = null) : ServerRequestInterface + { + $search = $entityType::searchRequest()->fromRequest($request); + $entity = $entityType::repository()->filterServerRequest($search)->loadAll(); + + if (! $entity ) { + throw new \InvalidArgumentException(sprintf("L'entré pour l'entité demandé (%s) est introuvable avec le ou les arguments fournis '%s'", $entityType, json_encode($request->getAttributes()))); + } + + return $request->withAttribute("lean.searchRequest", + [ + $resultKey ?: $entityType => new class($search, $entity) implements ApiSearchRequestInterface { + public function __construct( + public readonly SearchRequestInterface $search, + public readonly EntityCollection $collection, + ) {} + + public function getSearch(): SearchRequestInterface + { + return $this->search; + } + + public function getResult(): EntityCollection|EntityInterface + { + return $this->collection; + } + } + ] + $request->getAttribute("lean.searchRequest", []) + ); + } } \ No newline at end of file diff --git a/src/Lib/ApiSearchRequestInterface.php b/src/Lib/ApiSearchRequestInterface.php new file mode 100644 index 0000000..07031f3 --- /dev/null +++ b/src/Lib/ApiSearchRequestInterface.php @@ -0,0 +1,13 @@ +<?php + +namespace Lean\Api\Lib; + +use Ulmus\Entity\EntityInterface; +use Ulmus\EntityCollection; +use Ulmus\SearchRequest\SearchRequestInterface; + +interface ApiSearchRequestInterface +{ + public function getSearch() : SearchRequestInterface; + public function getResult() : EntityCollection|EntityInterface; +} \ No newline at end of file diff --git a/src/Lib/ControllerTrait.php b/src/Lib/ControllerTrait.php new file mode 100644 index 0000000..b246fbe --- /dev/null +++ b/src/Lib/ControllerTrait.php @@ -0,0 +1,13 @@ +<?php + +namespace Lean\Api\Lib; + +use Notes\Route\Attribute\Object\Route; + +#[Route(method: ['GET', 'POST', ])] +trait ControllerTrait +{ + use \Lean\ControllerTrait, \Lean\Api\LeanApiTrait { + \Lean\Api\LeanApiTrait::renderMarkdown insteadof \Lean\ControllerTrait; + } +} \ No newline at end of file diff --git a/src/Lib/FormContext.php b/src/Lib/FormContext.php new file mode 100644 index 0000000..4c922e9 --- /dev/null +++ b/src/Lib/FormContext.php @@ -0,0 +1,5 @@ +<?php + +namespace Lean\Api\Lib; + +class FormContext extends \Picea\Ui\Method\FormContext { } \ No newline at end of file diff --git a/src/Middleware/ApiRenderer.php b/src/Middleware/ApiRenderer.php index 2069e0c..0e74b5d 100644 --- a/src/Middleware/ApiRenderer.php +++ b/src/Middleware/ApiRenderer.php @@ -4,29 +4,44 @@ namespace Lean\Api\Middleware; use Laminas\Diactoros\Response\JsonResponse; use Lean\Factory\HttpFactory; +use Picea\Ui\Method\FormHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Lean\Api\{Factory\DebugFormFactoryInterface, Form, Entity}; class ApiRenderer implements MiddlewareInterface { + public function __construct( + protected DebugFormFactoryInterface $debugFormFactory, + ) { + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface { - try { + try { $response = $handler->handle($request); } - catch(\Exception $ex) { - if ( ! getenv('DEBUG') ) { + catch(\Throwable $ex) { + $errorId = $this->saveError($request, $ex)->id; + + if ($this->awaitingJson($request)) { return HttpFactory::createJsonResponse([ 'status' => 'failed', - 'message' => $ex->getMessage(), 'ts' => time(), - ], 400); - } - else { - throw $ex; + 'data' => [ + # 'error_id' => $errorId, + 'message' => $ex->getMessage(), + 'file' => $ex->getFile(), + 'line' => $ex->getLine(), + 'code' => $ex->getCode(), + 'backtrace' => $ex->getTrace(), + ] + ], 500); } + + throw $ex; } if ($response instanceof JsonResponse) { @@ -43,4 +58,30 @@ class ApiRenderer implements MiddlewareInterface { return $response; } + + protected function saveError(ServerRequestInterface $request, \Throwable $exception) : Entity\Error + { + $request = $request->withMethod('PUT')->withParsedBody([]); + + $context = $this->debugFormFactory->errorSaveContext($request); + + $context->sets([ + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTrace(), + ]); + + $form = $this->debugFormFactory->errorSave($entity = new Entity\Error()); + + new FormHandler($request, $form, $context); + + return $entity; + } + + protected function awaitingJson(ServerRequestInterface $request) : bool + { + return str_contains(strtolower($request->getHeaderLine('content-type')), 'json'); + } } diff --git a/src/RouteDescriptor.php b/src/RouteDescriptor.php index 2031254..ff0cd61 100644 --- a/src/RouteDescriptor.php +++ b/src/RouteDescriptor.php @@ -5,22 +5,27 @@ namespace Lean\Api; use Lean\Factory\HttpFactory; use Notes\ObjectReflection; use Notes\Route\Attribute\Method\Route; +use Notes\Security\SecurityHandler; use Picea\Extension\UrlExtension; +use Psr\Container\ContainerInterface; +use Taxus\Taxus; class RouteDescriptor { - public string $routeLine = <<<HTML - <li> - <span><a href='%s' title="%s" style='font-family:monospace;font-size:.85em'>%s</a> - %s</span> - <span style='color:#ac1b1b'>%s</span> - <small style="color:#374300">%s</small> - </li> -HTML; + protected SecurityHandler $securityHandler; + + protected Taxus $taxus; public function __construct( public object $controller, protected UrlExtension $urlExtension, - ) {} + ContainerInterface $container + ) { + if ($container->has(SecurityHandler::class) && $container->has(Taxus::class)) { + $this->securityHandler = $container->get(SecurityHandler::class); + $this->taxus = $container->get(Taxus::class); + } + } public function getRoutes() : array { @@ -47,6 +52,7 @@ HTML; 'description'=> $route->description, #'methods' =>implode(', ', (array)$route->method), 'methods' => (array) $route->method, + 'privileges' => $this->getPrivilegeFromRoute($method->name), ]; } } @@ -66,4 +72,32 @@ HTML; return implode('/', $paths); } + + /** + * @param string $method Which method are we inspecting + * @return false|array|null Returns NULL when securityHandler is undefined, FALSE when no security applies for this method and else an array of ['admin' => [ 'status' => true, 'description' => "' ], ...]. + */ + protected function getPrivilegeFromRoute(string $method) : null|false|array + { + if ( isset($this->securityHandler) ){ + $list = []; + + if ( $this->securityHandler->isLocked($this->controller::class, $method) ) { + foreach($this->taxus->list as $name => $definition) { + if ($definition[0]->testableArguments !== null) { + if ( $this->securityHandler->hasGrantPermission($this->controller::class, $method, ...$definition[0]->testableArguments) ) { + + } + } + } + } + else { + return false; + } + + return $list; + } + + return false; + } } \ No newline at end of file diff --git a/view/lean-api/form_descriptor.phtml b/view/lean-api/form_descriptor.phtml index 7681698..b2d1c97 100644 --- a/view/lean-api/form_descriptor.phtml +++ b/view/lean-api/form_descriptor.phtml @@ -6,8 +6,10 @@ <div class="forms"> {% foreach $forms as $name => $form %} - <div class="single-form" style="padding-left:15px;border-left: 3px solid #9ccce6;"> - <h4 class='form-name'>{{ $name }}</h4> + <div class="single-form" style="padding-left:15px;border-left: 3px solid #9ccce6;" data-form="{% json.html $form %}"> + <h4 class='form-name'> + <span>{{ $name }}</span> + </h4> <div class='description'>{{ $form['description'] }}</div> <hr style="margin-top:15px"> @@ -25,12 +27,24 @@ <div class="field-desc" style="margin-top:10px;;background:#fff;padding:5px;font-size:0.9em"> <div><u>Variable POST / champ JSON</u> : {{ $field['name'] }}</div> - <div><u>Type(s)</u> : {{ $field['type'] }}</div> + <div><u>Type(s)</u> : {{ implode(' | ', array_map(fn($e) => $e->type, $field['type'])) }}</div> <div><u>Nullable</u> : {{ yesOrNo($field['allowNulls']) }}</div> - <div><u>Pattern regex</u> : {{ $field['regexPattern'] }}</div> - <div><u>Taille min.</u> : {{ $field['minLength'] }}</div> - <div><u>Taille max.</u> : {{ $field['maxLength'] }}</div> - <div><u>Exemple</u> : {{ $field['example'] }}</div> + {% if $field['regexPattern'] !== null %} + <div><u>Pattern regex</u> : {{ $field['regexPattern'] }}</div> + {% endif %} + {% if $field['minLength'] !== null %} + <div><u>Taille min.</u> : {{ $field['minLength'] }}</div> + {% endif %} + {% if $field['maxLength'] !== null %} + <div><u>Taille max.</u> : {{ $field['maxLength'] }}</div> + {% endif %} + {% if $field['example'] !== null || $field['values'] %} + <div><u>Exemple</u> : {{ $field['example'] }}</div> + {% endif %} + + {% if $field['values'] %} + <div><u>Valeurs possibles</u> : [ <u>{{= implode('</u>, <u>', $field['values']) }}</u> ]</div> + {% endif %} </div> </li> {% endforeach %} diff --git a/view/lean-api/request_debugger.phtml b/view/lean-api/request_debugger.phtml new file mode 100644 index 0000000..0675d5d --- /dev/null +++ b/view/lean-api/request_debugger.phtml @@ -0,0 +1,241 @@ +{% use Ulmus\Api\Common\MethodEnum %} + +{% language.set "lean.api.request.debugger" %} + +<div id="request-debugger" class="hide"> + <div class="request-head"> + <div class="method" style=""> + {% ui.select "method", MethodEnum::generateArray(), post('method') %} + </div> + + <div class="url" style="flex-grow: 1;"> + {% ui:text "url" %} + <button class="request-btn">Envoyer</button> + </div> + </div> + + <div class="request-content"> + <div id="request" style="min-height: 150px; width:100%; border:1px solid #ededed;">{}</div> + </div> + + <div class="request-response hide"> + <hr> + <pre id="response" class="code" ace-theme="ace/theme/cloud9_day"></pre> + + <div class="response-head"> + <span class="response-code"></span> + <span class="response-message"></span> + </div> + </div> +</div> + +<style> + .request-head {display:flex;padding-top: 4px;border:1px solid #e1e1e1;border-bottom:0} + .request-head .method {width: 90px;text-align: center;line-height: 34px;font-weight: bold;} + .request-head .method select {background:none;border:0;font-size: inherit;text-align: center;color: inherit;font-weight: bold;} + .request-head .url input {width: calc(100% - 94px); border:0;color: #6f6f6f;background: #f9f9f9;font-size: 80%;font-weight: bold;} + .request-head .url button {font-size: 80%;width: 80px;border-radius:0;border: 1px solid #ccc;height:26px;padding: 0;background:#f9f9f9;color: #6f6f6f;cursor: pointer;} + .request-head .url button:active {filter:contrast(85%);} + + .response-head {display: flex;border:1px solid #9f9f9f;margin-bottom: 5px;} + .response-head .response-code {background:#dfdfdf;padding:0 5px;font-weight: bold;line-height: 29px;font-size: 80%;} + .response-head .response-message {padding:0 10px;background:#fbfbfb;line-height: 30px;font-size: 80%;} + + #response {max-height: 66vh;} +</style> + +<script src="{% asset 'static/ace/src-noconflict/ace.js' %}" type="text/javascript" charset="utf-8"></script> +<script src="{% asset 'static/ace/src-noconflict/ext-static_highlight.js' %}" type="text/javascript" charset="utf-8"></script> + +<script> + let requestDebugger = document.getElementById('request-debugger'), + requestHead = requestDebugger.querySelector(".request-head"), + requestContent = requestDebugger.querySelector(".request-content"), + responseHead = requestDebugger.querySelector(".response-head"), + responseResponse = requestDebugger.querySelector(".request-response"), + method = requestHead.querySelector(".method"), + input = requestHead.querySelector("[name='url']"), + button = requestHead.querySelector(".request-btn"); + + // Editor + let editor = ace.edit("request"); + editor.setTheme("ace/theme/cloud9_day"); + editor.session.setMode("ace/mode/json"); + + // Code highlighter + let highlight = ace.require("ace/ext/static_highlight"), + dom = ace.require("ace/lib/dom"), + responseEditorElement = responseResponse.querySelector('#response'); + + input.addEventListener('keydown', evt => evt.keyCode === 13 ? button.click() : null); + + document.addEventListener("DOMContentLoaded", (evt) => { + document.querySelectorAll(".form-name").forEach(elem => { + elem.insertAdjacentHTML('beforeend', '<a href="javascript:void(0)" class="btn debug">DEBUG</a>'); + + let formData = JSON.parse(elem.closest('.single-form').getAttribute('data-form')); + + elem.querySelector(".btn.debug").addEventListener("click", (btn) => { + requestDebugger.classList.toggle('hide', false); + + let json = {}; + + formData.fields.forEach((field) => { + json[field.name] = field.allowNulls ? null : ""; + }); + + editor.setValue(JSON.stringify(json,null,2), -1); + }); + }); + + document.querySelectorAll("li[class*='method-']").forEach( (url) => { + url.addEventListener("click", (evt) => { + evt.preventDefault(); + + let routeMethod = url.querySelector('.route-method'); + + method.style.color = getComputedStyle(routeMethod).getPropertyValue('background-color'); + method.querySelector('select').value = routeMethod.innerText; + + input.value = url.querySelector('.route-link a').getAttribute('href'); + + document.getElementById('request-debugger').classList.toggle('hide', false); + + replaceUrlVariableUsingData(); + + input.focus(); + }); + }); + + let aceMode; + + button.addEventListener("click", (evt) => { + let requestMethod = method.querySelector('select').value; + + if (requestMethod === 'DELETE' && ! confirm("Attention ! Vous allez lancer une procédure de suppression de données.\n\nContinuer ?")) { + return; + } + + launchRequest(requestMethod, input.value, editor.getValue()) + .then((response) => { + responseHead.querySelector('.response-code').innerText = response.status; + responseHead.querySelector('.response-message').innerText = response.statusText; + + aceMode = parseContentType(response); + + return response.text(); + }) + .then(body => { + if (aceMode === "json") { + body = JSON.stringify(JSON.parse(body), null, 2); + responseEditorElement.innerHTML = body; + } + else { + responseEditorElement.innerText = body; + } + + formatResponse("ace/mode/" + aceMode); + }); + + responseResponse.classList.toggle('hide', false); + }); + + let selectRoute = getQueryVariable("debug.route"), + presetData = getQueryVariable("debug.data"); + + if ( selectRoute ) { + if (presetData) { + editor.setValue(presetData); + } + + document.querySelector(`[data-name="${selectRoute}"]`).click(); + } + }); + + async function launchRequest(method = "POST", url = "", body = "{}") { + method = method.toUpperCase(); + + let responseData = { + method: method, + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + 'Authorization': 'Bearer {{ $this->session->jwt }}' + }, + redirect: "follow", + referrerPolicy: "no-referrer" + }; + + if ( [ "HEAD", "GET" ].indexOf(method) === -1 ) { + responseData.body = body; + } + + return await fetch(url, responseData); + } + + function formatResponse(mode) { + highlight(responseEditorElement, { + mode: mode, + theme: responseEditorElement.getAttribute("ace-theme"), + firstLineNumber: 1, + showGutter: responseEditorElement.getAttribute("ace-gutter"), + trim: true + }); + } + + function parseContentType(response) { + let type = response.headers.get('content-type').toLowerCase(); + + if ( type.indexOf('text/html') !== -1 ) { + return "html"; + } + else if ( type.indexOf('text/css') !== -1 ) { + return "css"; + } + else if ( type.indexOf('text/csv') !== -1 ) { + return "csv"; + } + else if ( type.indexOf('text/xml') !== -1 ) { + return "xml"; + } + else if ( type.indexOf('text/plain') !== -1 ) { + return "text"; + } + else if ( type.indexOf('application/javascript') !== -1 ) { + return "javascript"; + } + else if ( type.indexOf('application/json') !== -1 || type.indexOf('application/ld+json') !== -1 ) { + return "json"; + } + } + + function getQueryVariable(variable) { + retval = false; + + window.location.search.substring(1).split("&").forEach(vars => { + var pair = vars.split("="); + + if (pair.length === 2 && pair[0] === variable) { + return retval = decodeURI(pair[1]); + } + }); + + return retval; + } + + function replaceUrlVariableUsingData() { + try { + let vars = input.value.match(/[^{}]+(?=})/g), + body = JSON.parse(editor.getValue()); + + vars.forEach(v => { + if (body[v]) { + input.value = input.value.replace("{" + v + "}", body[v]); + } + }) + } + catch(e) {} + } +</script> \ No newline at end of file diff --git a/view/lean-api/route_descriptor.phtml b/view/lean-api/route_descriptor.phtml index bc9fb28..06fed63 100644 --- a/view/lean-api/route_descriptor.phtml +++ b/view/lean-api/route_descriptor.phtml @@ -3,19 +3,33 @@ <ul class="routes-wrapper"> {% foreach $routes as $route %} {% foreach $route['methods'] as $method %} - <li class="method-{{ strtolower($method) }}"> + <li class="method-{{ strtolower($method) }}" data-name="{{ $route['name'] }}"> <span class="route-method"> <span class="method-name">{{ strtoupper($method) }}</span> </span> + <span class="route-link"> <a href="{{ $route['route'] }}" title="{{ $route['path'] }}">{{ $route['cleaned'] }}</a> <span>-</span> <span>{{= $route['description'] }}</span> </span> - <small class="route-name">{{ $route['name'] }}</small> + + <small class="route-name"> + <span>{{ $route['name'] }}</span> + <br> + <span class="permissions"> + {% foreach ['admin', 'school' ] as $privilege %} + <u class="privilege">{{ $privilege }}</u> + {% endforeach %} + </span> + </small> </li> {% or %} <i style="color:#585858; padding:0 12px">{% _ "none" %}</i> {% endforeach %} {% endforeach %} -</ul> \ No newline at end of file +</ul> + +<style> + .permissions {color:#888} +</style> \ No newline at end of file diff --git a/view/lean/layout/docs.phtml b/view/lean/layout/docs.phtml index b838b8b..267738a 100644 --- a/view/lean/layout/docs.phtml +++ b/view/lean/layout/docs.phtml @@ -16,6 +16,7 @@ <meta name="theme-color" content="#ffffff"> <style> + *, *:before, *:after {box-sizing: border-box;} a{color:#e44a00}h3{background:#e1e1e1;padding:6px 12px}ul{background:#e9e9fd;padding-top:20px;padding-bottom:20px;border:1px solid #e3e3ec}ul li + li{margin-top:8px}li > em{font-size:0.75rem;color:gray}body{font-family:Helvetica, 'Helvetica Neuve', Arial, Tahoma, sans-serif;font-size:17px;color:#333}h1,h2,h3,h4,h5,h6{color:#222;margin:0 0 20px}dl,ol,p,pre,table,ul{margin:0 0 20px}h1,h2,h3{line-height:1.1}h1{font-size:20px;text-align:right;color:#387eea;font-weight:bold}h2{color:#393939}h3,h4,h5,h6{color:#494949}h3{display:flex}h3 > code{margin-right:5px;color:#b52dac}h3 > strong{margin-left:auto}a{color:#39c;font-weight:400;text-decoration:none}a small{font-size:11px;color:#777;margin-top:-0.6em;display:block}.wrapper{width:860px;margin:0 auto}blockquote{border-left:1px solid #e5e5e5;margin:0;padding:0 0 0 20px;font-style:italic}code,pre{font-size:12px}pre{padding:8px 15px;background:#f8f8f8;border-radius:5px;border:1px solid #e5e5e5;overflow-x:auto}table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:5px 10px;border-bottom:1px solid #e5e5e5}dt{color:#444;font-weight:700}th{color:#444}img{max-width:100%}header{width:270px;float:left;position:fixed}header ul{list-style:none;height:40px;padding:0;background:#eee;background:-moz-linear-gradient(top, #f8f8f8 0%, #dddddd 100%);background:-webkit-gradient(linear, left top, left bottom, color-stop(0%,#f8f8f8), color-stop(100%,#dddddd));background:-webkit-linear-gradient(top, #f8f8f8 0%,#dddddd 100%);background:-o-linear-gradient(top, #f8f8f8 0%,#dddddd 100%);background:-ms-linear-gradient(top, #f8f8f8 0%,#dddddd 100%);background:linear-gradient(top, #f8f8f8 0%,#dddddd 100%);border-radius:5px;border:1px solid #d2d2d2;box-shadow:inset #fff 0 1px 0, inset rgba(0,0,0,0.03) 0 -1px 0;width:270px}header li{width:89px;float:left;border-right:1px solid #d2d2d2;height:40px}header ul a{line-height:1;font-size:11px;color:#999;display:block;text-align:center;padding-top:6px;height:40px}strong{color:#222;font-weight:700}header ul li + li{width:88px;border-left:1px solid #fff}header ul li + li + li{border-right:none;width:89px}header ul a strong{font-size:14px;display:block;color:#222}section{width:500px;float:right;padding-bottom:50px}small{font-size:11px}hr{border:0;background:#e5e5e5;height:1px;margin:0 0 20px}footer{width:270px;float:left;position:fixed;bottom:50px}@media print, screen and (max-width: 960px){div.wrapper{width:auto;margin:0}footer,header,section{float:none;position:static;width:auto}header{padding-right:320px}section{border:1px solid #e5e5e5;border-width:1px 0;padding:20px 0;margin:0 0 20px}header a small{display:inline}header ul{position:absolute;right:50px;top:52px}}@media print, screen and (max-width: 720px){body{word-wrap:break-word}header{padding:0}header p.view,header ul{position:static}code,pre{word-wrap:normal}}@media print, screen and (max-width: 480px){body{padding:15px}header ul{display:none}}@media print{body{padding:0.4in;font-size:12pt;color:#444}}#wrapper{margin-left:auto;margin-right:auto;background-color:white}.ca-menu{list-style:none;padding:0;margin:20px auto}#navi{padding-top:15px;padding-right:15px;float:right;width:420px}#title{padding-left:15px;width:460px;float:left}div.clear{clear:both}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.2em}h5{font-size:1em;font-weight:bold}h6{font-size:1em;font-weight:bold}h1,h2,h3,h4,h5,h6{font-weight:normal;line-height:2.5rem;margin:1rem 0}.post p{max-width:580px}ol.list,ul.list{padding-left:3.333em;max-width:580px}.post h2{border-bottom:1px solid #EDEDED}h1:nth-child(1),h2:nth-child(1),h3:nth-child(1),h4:nth-child(1),h5:nth-child(1),h6:nth-child(1){margin-top:0}body{padding:1em}#wrapper{padding:1em}@media (min-width: 43.75em){body{padding:2em}#wrapper{padding:2em}}@media (min-width: 62em){body{padding:3em}#wrapper{max-width:740px;padding:3em}} ol{background: #eff4f2;padding-top:20px;padding-bottom:20px;border:1px solid #e3e3ec} h4{background:#e0f7ed;padding:6px 12px; font-weight: bold!important;font-size:100%;margin-top:0} @@ -23,7 +24,9 @@ h3 {display: flex;align-items: center;height: 60px;padding: 0 15px 0 15px;font-variant: small-caps;} li.odd-even{border-top:1px solid #ccc;margin:10px 0;padding:15px 15px 10px 5px} li.odd-even:first-child{border:0} - input, button {padding:5px; font-size:1em;margin-top:10px} + input, button {padding:5px; font-size:1em;margin:0} + + .hide {display:none!important} ul {background:#f4f4f4; list-style: none; padding-left:20px} ul ul {margin: 0;border: 0;padding: 5px 30px;} @@ -32,16 +35,19 @@ .field-desc > div {padding:5px;} .forms ol {background: #ccdef2;} .forms li {border-color: #859aae;} - .forms .form-name {background: #9cc5e6;color: #284168;font-size:110%} + .forms .form-name {background: #9cc5e6;display:flex;justify-content: space-between} + .forms .form-name span {color: #284168;font-size:110%} + .forms .form-name .btn {background:rgba(50,50,50,0.5);padding:0 15px;border: 1px solid rgba(50,50,50,0.8);font-family: 'Déja Vu', 'Courier New', Courier, monospace, serif;font-size: 90%;color: #fff;text-decoration: underline;} .routes-wrapper {padding:8px} - .routes-wrapper li {display:flex;border: 1px solid #ccc;align-items: stretch;} + .routes-wrapper li {display:flex;border: 1px solid #ccc;align-items: stretch;cursor:pointer} + .routes-wrapper li:hover {filter: contrast(85%)} .routes-wrapper li + li {margin-top: 5px;} .routes-wrapper .route-method {display:flex;align-items: center; justify-content: center; line-height: 1.8rem;padding:6px 5px 0 5px;min-width:80px;text-align:center;font-weight:bold;color:#fff;} .routes-wrapper .route-link {line-height: 1.8rem;padding:0 10px;display: flex;align-items: center;} .routes-wrapper .route-link span {margin-left: 7px;} - .routes-wrapper .route-link a {font-family:monospace;font-size:.85em} - .routes-wrapper .route-name {margin-left:auto;font-weight:bold;min-width: 20%;text-align: right;background:rgba(0, 0, 0, 0.02);line-height: 1.8rem;padding:6px 7px 0 5px;} + .routes-wrapper .route-link a {font-family:monospace;font-size:.85em;white-space: nowrap;} + .routes-wrapper .route-name {margin-left:auto;font-weight:bold;min-width: 20%;text-align: right;background:rgba(0, 0, 0, 0.02);line-height: 1.4rem;padding:6px 7px 0 5px;align-content: center;} .routes-wrapper li.method-get {background:#e7eff7;border-color: #bfcfdd;} .routes-wrapper li.method-get .route-method {background:#0f6ab4;} .routes-wrapper li.method-get .route-link a, .routes-wrapper li.method-get .route-name {color: #0f6ab4;}