From f5317f906d83efa3ef52932be9a59f9e49af5807 Mon Sep 17 00:00:00 2001 From: Dave Mc Nicoll Date: Mon, 14 Oct 2024 15:28:35 +0000 Subject: [PATCH] - First commit --- LICENSE | 21 ++++++ composer.json | 47 ++++++++++++ src/Attribute/ContextField.php | 16 ++++ src/DescriptorTrait.php | 31 ++++++++ src/EntityDescriptor.php | 130 +++++++++++++++++++++++++++++++++ src/FormDescriptor.php | 80 ++++++++++++++++++++ src/LeanApiTrait.php | 49 +++++++++++++ src/Middleware/ApiRenderer.php | 46 ++++++++++++ src/RouteDescriptor.php | 64 ++++++++++++++++ 9 files changed, 484 insertions(+) create mode 100644 LICENSE create mode 100644 composer.json create mode 100644 src/Attribute/ContextField.php create mode 100644 src/DescriptorTrait.php create mode 100644 src/EntityDescriptor.php create mode 100644 src/FormDescriptor.php create mode 100644 src/LeanApiTrait.php create mode 100644 src/Middleware/ApiRenderer.php create mode 100644 src/RouteDescriptor.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2711bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Dave Mc Nicoll + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b396bae --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "mcnd/lean-api", + "description": "Tooling to facilitate an API subset for Lean's application", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Dave Mc Nicoll", + "email": "info@mcnd.ca" + } + ], + "require": { + "php": "^8.2", + "php-di/php-di": "dev-master", + "ext-json": "*", + "mcnd/lean": "dev-master" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://git.mcnd.ca/mcndave/lean.git" + } + ], + "autoload": { + "psr-4": { + "Lean\\Api\\": "src/" + } + }, + "scripts": { + "post-install-cmd": [ + "Lean\\Composer::postInstall" + ], + "post-update-cmd": [ + "Lean\\Composer::postUpdate" + ] + }, + "extra" : { + "lean" : { + "autoload": { + "definitions" : [ + ], + "config": [ + ] + } + } + } +} \ No newline at end of file diff --git a/src/Attribute/ContextField.php b/src/Attribute/ContextField.php new file mode 100644 index 0000000..0b04e15 --- /dev/null +++ b/src/Attribute/ContextField.php @@ -0,0 +1,16 @@ +name; + } + + return $value; + } + + protected function yesOrNo(bool $toggle) : string + { + return sprintf("%s", $toggle ? 'green' : '#ac1b1b', $toggle ? 'oui' : 'non'); + } + + protected function length(int|null $length) : string + { + return sprintf("%s", $length ? 'black' : 'gray', $length ?? "non-défini"); + } +} \ No newline at end of file diff --git a/src/EntityDescriptor.php b/src/EntityDescriptor.php new file mode 100644 index 0000000..674bc08 --- /dev/null +++ b/src/EntityDescriptor.php @@ -0,0 +1,130 @@ + +

%s

+
%s
+
+
Champs
+
    %s
+
+

%s

+
Requêtes
+
%s
+
    %s
+ + HTML; + + public string $entityField = << +
+
$%s %s
+
+ +
+
Champ SQL : %s
+
Attribut : %s
+
Type : %s
+
Nullable %s
+
Taille : %s
+
Lecture seule : %s
+
+ + HTML; + + public string $searchRequestField = << +
+
$%s %s
+
+ +
+
Paramètre de requête (GET) : %s
+
Attribut : %s
+
Type : %s
+
Nullable : %s
+
Valeur par défault : %s
+
+ + HTML; + + + public function __construct() {} + + /** + * @return Returns HTML describing entity fields + */ + public function describe(...$entities) : string + { + $html = ""; + + foreach($entities as $entity) { + $propertyHtml = $searchRequestHtml = ""; + $entityName = is_object($entity) ? $entity::class : $entity; + $reflector = new ObjectReflection($entityName); + + $table = $reflector->reflectClass()->getAttribute(Table::class); + + foreach($reflector->reflectProperties() as $property) { + $field = $property->getAttribute(Field::class); + + if ($field) { + $types = $property->getTypes(); + $propertyHtml .= sprintf( + $this->entityField, + $property->name, + $field->object->description, + $field->object->name ?: $property->name, + $field->tag, + $field->object->type ?? implode(' | ', array_map(fn($e) => $e->type, $types)), + $this->yesOrNo($property->allowsNull()), + $this->length($field->object->length ?? null), + $this->yesOrNo($field->object->readonly), + ); + } + } + + $searchRequestReflector = new ObjectReflection($entityName::searchRequest()::class); + + $searchRequestParameter = $searchRequestReflector->reflectClass()->getAttribute(SearchRequestParameter::class); + + foreach($searchRequestReflector->reflectProperties() as $property) { + $field = $property->getAttribute(SearchParameter::class); + + if ($field) { + $types = $property->getTypes(); + + $searchRequestHtml .= sprintf( + $this->searchRequestField, + $property->name, + $field->object->description, + $field->object->parameter ? implode(', ', $field->object->getParameters()) : $property->name, + $field->tag, + implode(' | ', array_map(fn($e) => $e->type, $types)), + $this->yesOrNo($property->allowsNull()), + $this->displayValue($property->value ?? "aucune") + ); + } + } + + $html .= sprintf($this->entityTitle, $reflector->reflectClass()->getClassName(), $table->object->description ?? null, $propertyHtml, $reflector->reflectClass()->getClassName() . "::searchRequest()", $searchRequestParameter->object->description ?? "", $searchRequestHtml); + } + + return $html; + } + +} \ No newline at end of file diff --git a/src/FormDescriptor.php b/src/FormDescriptor.php new file mode 100644 index 0000000..8514bb3 --- /dev/null +++ b/src/FormDescriptor.php @@ -0,0 +1,80 @@ + +

%s

+
%s
+
+
Champs
+
    %s
+ + HTML; + + + public string $formField = << +
+
$%s %s
+
+ +
+
Variable POST / champ JSON : %s
+
Type : %s
+
Nullable : %s
+
Pattern regex : %s
+
Taille min. : %s
+
Taille max. : %s
+
Exemple : %s
+
+ + HTML; + + /** + * @return Returns HTML describing given controller's routes + */ + public function describe(...$forms) : string + { + $html = ""; + + foreach($forms as $form) { + $propertyHtml = ""; + $entityName = is_object($form) ? $form::class : $form; + $reflector = new ObjectReflection($entityName); + + $context = $reflector->reflectClass()->getAttribute(ContextField::class); + + foreach($reflector->reflectProperties() as $property) { + $field = $property->getAttribute(ContextField::class); + + if ($field) { + $types = $property->getTypes(); + $propertyHtml .= sprintf( + $this->formField, + $property->name, + $field->object->description, + $property->name, + $field->object->type ?? implode(' | ', array_map(fn($e) => $e->type, $types)), + $this->yesOrNo($property->allowsNull()), + $field->object->regexFormat ?? "aucun", + $field->object->minLength ?? "aucune", + $field->object->maxLength ?? "aucune", + $field->object->example + ); + } + } + + $html .= sprintf($this->formTitle, $reflector->reflectClass()->getClassName(), $context->object->description ?? null, $propertyHtml, ); + } + + return $html; + } +} \ No newline at end of file diff --git a/src/LeanApiTrait.php b/src/LeanApiTrait.php new file mode 100644 index 0000000..a825f4b --- /dev/null +++ b/src/LeanApiTrait.php @@ -0,0 +1,49 @@ +convert(file_get_contents($filepath))->getContent(); + + if (str_contains($markdown, '{route:descriptor}')) + { + $describe = (new RouteDescriptor($this))->describe(); + $markdown = str_replace('{route:descriptor}', $describe, $markdown); + } + + if (str_contains($markdown, '{entity:descriptor}')) + { + $describe = (new EntityDescriptor())->describe(... $entities); + $markdown = str_replace('{entity:descriptor}', $describe, $markdown); + } + + + if (str_contains($markdown, '{form:descriptor}')) + { + $describe = (new FormDescriptor())->describe(... $forms); + $markdown = str_replace('{form:descriptor}', $describe, $markdown); + } + + return $this->renderView("lean/layout/docs", get_defined_vars()); + } + + #[Ignore] + protected function output(\JsonSerializable|array $data, int $count) { + return $this->renderJson([ + 'data' => $data, + 'count' => $count, + ]); + } +} \ No newline at end of file diff --git a/src/Middleware/ApiRenderer.php b/src/Middleware/ApiRenderer.php new file mode 100644 index 0000000..2069e0c --- /dev/null +++ b/src/Middleware/ApiRenderer.php @@ -0,0 +1,46 @@ +handle($request); + } + catch(\Exception $ex) { + if ( ! getenv('DEBUG') ) { + return HttpFactory::createJsonResponse([ + 'status' => 'failed', + 'message' => $ex->getMessage(), + 'ts' => time(), + ], 400); + } + else { + throw $ex; + } + } + + if ($response instanceof JsonResponse) { + $payload = $response->getPayload(); + + # For now, we match only response having a 'data' field + if (isset($payload['data'])) { + return HttpFactory::createJsonResponse([ + 'status' => 'success', + 'ts' => time(), + ] + $payload, 200); + } + } + + return $response; + } +} diff --git a/src/RouteDescriptor.php b/src/RouteDescriptor.php new file mode 100644 index 0000000..f4fa63d --- /dev/null +++ b/src/RouteDescriptor.php @@ -0,0 +1,64 @@ + + %s - %s + %s + %s + +HTML; + + public function __construct( + public object $controller, + ) {} + + /** + * @return Returns HTML describing given controller's routes + */ + public function describe() : string + { + $html = ""; + + $reflector = new ObjectReflection($this->controller::class); + + $attribute = $reflector->reflectClass()->getAttribute(\Notes\Route\Attribute\Object\Route::class); + + $base = $attribute ? $attribute->object->base : ""; + + foreach($reflector->reflectMethods() as $method) { + foreach(array_reverse($method->getAttributes(Route::class)) as $routeAttribute) { + $route = $routeAttribute->object; + $path = rtrim($route->route, '/'); + $cleaned = $this->cleanRouteFromRegex($base.$path); + $url = url($cleaned); + + $html .= sprintf($this->routeLine, $url, $base.$path, $cleaned, $route->description, implode(', ', (array)$route->method), $route->name ); + } + } + + return sprintf('', $html); + } + + protected function cleanRouteFromRegex(string $route) : string + { + $paths = explode('/', $route); + + foreach($paths as &$path) { + list($newPath, ) = explode(':', $path); + + $path = $newPath === $path ? $path : "$newPath}"; + } + + return implode('/', $paths); + } +} \ No newline at end of file