From 2ed937cd7d60e176f88acc2dbee79f842a2f92b3 Mon Sep 17 00:00:00 2001 From: rtcoder Date: Wed, 17 Jun 2026 20:03:13 +0200 Subject: [PATCH] Complete OpenAPI documentation support --- README.md | 3 +- REFACTORING.md | 1 + docs/index.html | 27 ++- src/Console/Commands/OpenApi.php | 24 +- src/Console/Quality/QualityChecks.php | 29 +-- src/OpenApi/Attributes/Deprecated.php | 10 + src/OpenApi/Attributes/Description.php | 13 ++ src/OpenApi/Attributes/Response.php | 16 ++ src/OpenApi/Attributes/Schema.php | 23 ++ src/OpenApi/Attributes/Security.php | 18 ++ src/OpenApi/Attributes/Summary.php | 13 ++ src/OpenApi/Attributes/Tag.php | 13 ++ src/OpenApi/OpenApiGenerator.php | 310 ++++++++++++++++++++++--- src/OpenApi/OpenApiLivePage.php | 220 ++++++++++++++++-- src/OpenApi/OpenApiValidator.php | 96 ++++++++ tests/Feature/OpenApiTest.php | 32 ++- tests/Fixtures/TestControllers.php | 30 ++- 17 files changed, 785 insertions(+), 93 deletions(-) create mode 100644 src/OpenApi/Attributes/Deprecated.php create mode 100644 src/OpenApi/Attributes/Description.php create mode 100644 src/OpenApi/Attributes/Response.php create mode 100644 src/OpenApi/Attributes/Schema.php create mode 100644 src/OpenApi/Attributes/Security.php create mode 100644 src/OpenApi/Attributes/Summary.php create mode 100644 src/OpenApi/Attributes/Tag.php create mode 100644 src/OpenApi/OpenApiValidator.php diff --git a/README.md b/README.md index 2fed748..b04697d 100644 --- a/README.md +++ b/README.md @@ -465,10 +465,11 @@ Generate OpenAPI documentation from registered routes: ```sh ./shift openapi ./shift openapi --output=docs/openapi.json +./shift openapi --validate ./shift openapi --live ``` -The generator reads module routes from the same router used by the HTTP runtime and emits OpenAPI 3.0 JSON. Live mode starts a local documentation server at `http://127.0.0.1:8088` by default. Use `--host=` and `--port=` to change the binding. +The generator reads module routes from the same router used by the HTTP runtime and emits OpenAPI 3.0 JSON. It understands routing attributes, parameter binding attributes, request DTO rules, response metadata, and OpenAPI attributes such as `#[Summary]`, `#[Description]`, `#[Tag]`, `#[Response]`, `#[Deprecated]`, `#[Security]`, and `#[Schema]`. Live mode starts a local documentation server at `http://127.0.0.1:8088` by default. Use `--host=` and `--port=` to change the binding. Run the example module command: diff --git a/REFACTORING.md b/REFACTORING.md index 25e3a2e..ffb9d78 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -42,6 +42,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Developer documentation organized into a Laravel-like guide. - [x] CLI quality gate with `shift lint` and `shift qa`. - [x] OpenAPI JSON generation from registered module routes with `shift openapi`. +- [x] OpenAPI documentation attributes, validation, and live Swagger-like viewer. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: diff --git a/docs/index.html b/docs/index.html index 478c04b..0a8e171 100644 --- a/docs/index.html +++ b/docs/index.html @@ -694,15 +694,40 @@

OpenAPI

./shift openapi
 ./shift openapi --output=docs/openapi.json
+./shift openapi --validate
 ./shift openapi --live

The generator reads route paths, HTTP methods, controller handlers, path parameters, query parameters, request body attributes, request DTO rules, response status attributes, and response header attributes.

+
use Shift\OpenApi\Attributes\Description;
+use Shift\OpenApi\Attributes\Response;
+use Shift\OpenApi\Attributes\Schema;
+use Shift\OpenApi\Attributes\Security;
+use Shift\OpenApi\Attributes\Summary;
+use Shift\OpenApi\Attributes\Tag;
+
+#[Tag('Users')]
+final class UserController
+{
+    #[Summary('Create a user')]
+    #[Description('Creates a user account from a JSON request body.')]
+    #[Security('bearerAuth')]
+    #[Response(201, 'User created')]
+    public function store(
+        #[Schema(format: 'email')]
+        string $email
+    ): array {
+        return ['email' => $email];
+    }
+}
+ +

Available OpenAPI attributes include Summary, Description, Tag, Response, Deprecated, Security, and Schema. Validation mode checks the generated document for required OpenAPI fields, unique operation ids, responses, and declared path parameters.

+
./shift help openapi
 ./shift api:docs --output=storage/openapi.json
 ./shift openapi --live --host=127.0.0.1 --port=8088
-

Live mode writes the generated JSON into a temporary directory and starts a local documentation server with a lightweight Swagger-like HTML viewer.

+

Live mode writes the generated JSON into a temporary directory and starts a local documentation server with a lightweight Swagger-like HTML viewer, endpoint search, tag filtering, dark mode, and JSON download.

diff --git a/src/Console/Commands/OpenApi.php b/src/Console/Commands/OpenApi.php index 70b606b..6156822 100644 --- a/src/Console/Commands/OpenApi.php +++ b/src/Console/Commands/OpenApi.php @@ -7,6 +7,7 @@ use Shift\Modules\ModuleLoader; use Shift\OpenApi\OpenApiGenerator; use Shift\OpenApi\OpenApiLivePage; +use Shift\OpenApi\OpenApiValidator; use Shift\Routing\Router\Router; #[\Shift\Console\Attributes\Command('openapi', aliases: ['api:docs'], group: 'documentation')] @@ -27,12 +28,31 @@ public function execute(mixed ...$args): void $outputPath = $this->outputPath($args); $live = $this->hasOption($args, '--live'); + $validate = $this->hasOption($args, '--validate'); - if ($outputPath === null && !$live) { + if ($validate) { + $errors = (new OpenApiValidator())->validate($document); + + if ($errors !== []) { + $cli = new Cli(); + + foreach ($errors as $error) { + $cli->error($error); + } + + exit(1); + } + } + + if ($outputPath === null && !$live && !$validate) { echo $json . PHP_EOL; return; } + if ($validate) { + (new Cli())->success('OpenAPI document is valid.'); + } + if ($outputPath !== null) { $this->writeFile($outputPath, $json . PHP_EOL); (new Cli())->success('OpenAPI document written to ' . $outputPath); @@ -45,7 +65,7 @@ public function execute(mixed ...$args): void public function getHelp(): string { - return 'Usage: ./shift openapi [--output=docs/openapi.json] [--live] [--host=127.0.0.1] [--port=8088]'; + return 'Usage: ./shift openapi [--output=docs/openapi.json] [--validate] [--live] [--host=127.0.0.1] [--port=8088]'; } public function getDescription(): string diff --git a/src/Console/Quality/QualityChecks.php b/src/Console/Quality/QualityChecks.php index ffdd9c4..8534552 100644 --- a/src/Console/Quality/QualityChecks.php +++ b/src/Console/Quality/QualityChecks.php @@ -96,34 +96,9 @@ public function routeList(): CheckResult public function openApiDocument(): CheckResult { - $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($this->projectRoot() . '/shift') . ' openapi 2>&1'; - $previousDirectory = getcwd(); - - if ($previousDirectory !== false) { - chdir($this->projectRoot()); - } - - try { - exec($command, $output, $exitCode); - } finally { - if ($previousDirectory !== false) { - chdir($previousDirectory); - } - } - - if ($exitCode !== 0) { - $details = trim(implode(' ', array_slice($output, -3))); - - return CheckResult::fail('OpenAPI document', $details !== '' ? $details : 'Command failed with exit code ' . $exitCode); - } - - $document = json_decode(implode("\n", $output), true); - - if (!is_array($document) || ($document['openapi'] ?? null) !== '3.0.3') { - return CheckResult::fail('OpenAPI document', 'Generated document is not valid OpenAPI JSON'); - } + $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($this->projectRoot() . '/shift') . ' openapi --validate 2>&1'; - return CheckResult::ok('OpenAPI document', './shift openapi passed'); + return $this->runShellCheck('OpenAPI document', $command, './shift openapi --validate passed'); } private function runShellCheck(string $name, string $command, string $successDetails): CheckResult diff --git a/src/OpenApi/Attributes/Deprecated.php b/src/OpenApi/Attributes/Deprecated.php new file mode 100644 index 0000000..19f1cfb --- /dev/null +++ b/src/OpenApi/Attributes/Deprecated.php @@ -0,0 +1,10 @@ + $enum + */ + public function __construct( + public readonly ?string $type = null, + public readonly ?string $format = null, + public readonly ?string $description = null, + public readonly ?string $itemsType = null, + public readonly array $enum = [], + public readonly ?bool $required = null, + public readonly ?bool $nullable = null + ) { + } +} diff --git a/src/OpenApi/Attributes/Security.php b/src/OpenApi/Attributes/Security.php new file mode 100644 index 0000000..229214d --- /dev/null +++ b/src/OpenApi/Attributes/Security.php @@ -0,0 +1,18 @@ + $scopes + */ + public function __construct( + public readonly string $name, + public readonly array $scopes = [] + ) { + } +} diff --git a/src/OpenApi/Attributes/Summary.php b/src/OpenApi/Attributes/Summary.php new file mode 100644 index 0000000..9a5b7af --- /dev/null +++ b/src/OpenApi/Attributes/Summary.php @@ -0,0 +1,13 @@ +statusCode($method); $operation = [ 'operationId' => $this->operationId($controller, $method), - 'tags' => [$this->tag($controller)], - 'responses' => [ - (string) $statusCode => $this->response($method, $statusCode), - ], + 'tags' => $this->tags($controller, $method), + 'responses' => $this->responses($method, $statusCode), ]; + $summary = $this->summary($method); + + if ($summary !== null) { + $operation['summary'] = $summary; + } + + $description = $this->description($method); + + if ($description !== null) { + $operation['description'] = $description; + } + + if ($method->getAttributes(Deprecated::class) !== []) { + $operation['deprecated'] = true; + } + + $security = $this->security($controller, $method); + + if ($security !== []) { + $operation['security'] = $security; + } + $parameters = $this->parameters($route, $method); if ($parameters !== []) { @@ -77,10 +105,29 @@ private function operation(Route $route): array return $operation; } - private function response(ReflectionMethod $method, int $statusCode): array + private function responses(ReflectionMethod $method, int $defaultStatusCode): array + { + $responses = []; + + foreach ($method->getAttributes(OpenApiResponse::class) as $attribute) { + /** @var OpenApiResponse $response */ + $response = $attribute->newInstance(); + $responses[(string) $response->status] = $this->response($method, $response->status, $response->description, $response->type); + } + + if ($responses === []) { + $responses[(string) $defaultStatusCode] = $this->response($method, $defaultStatusCode); + } + + ksort($responses); + + return $responses; + } + + private function response(ReflectionMethod $method, int $statusCode, ?string $description = null, ?string $type = null): array { $response = [ - 'description' => $this->responseDescription($statusCode), + 'description' => $description ?? $this->responseDescription($statusCode), ]; $headers = $this->responseHeaders($method); @@ -89,12 +136,10 @@ private function response(ReflectionMethod $method, int $statusCode): array $response['headers'] = $headers; } - if ($statusCode !== 204 && $this->returnsJson($method)) { + if ($statusCode !== 204 && ($type !== null || $this->returnsJson($method))) { $response['content'] = [ 'application/json' => [ - 'schema' => [ - 'type' => 'object', - ], + 'schema' => $this->schemaForPhpType($type ?? 'object'), ], ]; } @@ -142,7 +187,7 @@ private function parameters(Route $route, ReflectionMethod $method): array $queryParameter = $this->queryParameterName($parameter); if ($queryParameter !== null) { - $parameters[] = $this->parameter($queryParameter, 'query', $parameter, !$parameter->allowsNull() && !$parameter->isDefaultValueAvailable()); + $parameters[] = $this->parameter($queryParameter, 'query', $parameter, $this->isRequiredParameter($parameter)); } } @@ -191,7 +236,7 @@ private function requestBody(ReflectionMethod $method): ?array $properties[$bodyKey] = $this->schemaForParameter($parameter); - if (!$parameter->allowsNull() && !$parameter->isDefaultValueAvailable()) { + if ($this->isRequiredParameter($parameter)) { $required[] = $bodyKey; } } @@ -229,9 +274,10 @@ private function schemaForDto(string $class): array if (is_subclass_of($class, RequestDto::class)) { foreach ($class::rules() as $field => $rules) { - $schema['properties'][$field] = $this->schemaForRules($rules); + $property = $this->dtoProperty($class, (string) $field); + $schema['properties'][$field] = $this->schemaForRules($rules, $property); - if ($this->rulesRequireField($rules)) { + if ($this->rulesRequireField($rules, $property)) { $required[] = $field; } } @@ -244,35 +290,51 @@ private function schemaForDto(string $class): array return $schema; } - private function schemaForRules(mixed $rules): array + private function schemaForRules(mixed $rules, ?ReflectionProperty $property = null): array { - $rules = is_array($rules) ? $rules : explode('|', (string) $rules); - $rules = array_map(static fn (string $rule): string => strtolower(strtok($rule, ':') ?: $rule), $rules); - - if (in_array('integer', $rules, true) || in_array('int', $rules, true)) { - return ['type' => 'integer']; + $normalizedRules = $this->normalizeRules($rules); + $schemaAttribute = $this->schemaAttribute($property); + + if ($schemaAttribute?->type !== null) { + $schema = $this->schemaForPhpType($schemaAttribute->type); + } elseif ($property !== null && $property->getType() instanceof ReflectionNamedType) { + $schema = $this->schemaForPhpType($property->getType()->getName()); + } elseif (in_array('integer', $normalizedRules, true) || in_array('int', $normalizedRules, true)) { + $schema = ['type' => 'integer']; + } elseif (in_array('numeric', $normalizedRules, true) || in_array('float', $normalizedRules, true)) { + $schema = ['type' => 'number']; + } elseif (in_array('boolean', $normalizedRules, true) || in_array('bool', $normalizedRules, true)) { + $schema = ['type' => 'boolean']; + } elseif (in_array('array', $normalizedRules, true)) { + $schema = ['type' => 'array', 'items' => ['type' => $schemaAttribute?->itemsType ?? 'string']]; + } else { + $schema = ['type' => 'string']; } - if (in_array('numeric', $rules, true) || in_array('float', $rules, true)) { - return ['type' => 'number']; + if (in_array('email', $normalizedRules, true)) { + $schema['format'] = 'email'; } - if (in_array('boolean', $rules, true) || in_array('bool', $rules, true)) { - return ['type' => 'boolean']; + if (in_array('date', $normalizedRules, true)) { + $schema['format'] = 'date'; } - if (in_array('array', $rules, true)) { - return ['type' => 'array', 'items' => ['type' => 'string']]; + if (in_array('datetime', $normalizedRules, true) || in_array('date_time', $normalizedRules, true)) { + $schema['format'] = 'date-time'; } - return ['type' => 'string']; + return $this->applySchemaAttribute($schema, $schemaAttribute); } - private function rulesRequireField(mixed $rules): bool + private function rulesRequireField(mixed $rules, ?ReflectionProperty $property = null): bool { - $rules = is_array($rules) ? $rules : explode('|', (string) $rules); + $schemaAttribute = $this->schemaAttribute($property); - return in_array('required', array_map('strtolower', $rules), true); + if ($schemaAttribute?->required !== null) { + return $schemaAttribute->required; + } + + return in_array('required', $this->normalizeRules($rules), true); } private function bodyDtoClass(ReflectionParameter $parameter): ?string @@ -346,27 +408,42 @@ private function parameter(string $name, string $in, ReflectionParameter $parame 'in' => $in, 'required' => $required, 'schema' => $this->schemaForParameter($parameter), - ]; + ] + $this->descriptionFragment($parameter); } private function schemaForParameter(ReflectionParameter $parameter): array { + $schemaAttribute = $this->schemaAttribute($parameter); + + if ($schemaAttribute?->type !== null) { + return $this->applySchemaAttribute($this->schemaForPhpType($schemaAttribute->type), $schemaAttribute); + } + $type = $parameter->getType(); if (!$type instanceof ReflectionNamedType) { - return ['type' => 'string']; + return $this->applySchemaAttribute(['type' => 'string'], $schemaAttribute); } - return $this->schemaForPhpType($type->getName()); + $schema = $this->schemaForPhpType($type->getName()); + + if ($parameter->allowsNull()) { + $schema['nullable'] = true; + } + + return $this->applySchemaAttribute($schema, $schemaAttribute); } private function schemaForPhpType(string $type): array { return match (ltrim($type, '\\')) { - 'int' => ['type' => 'integer'], - 'float' => ['type' => 'number'], - 'bool' => ['type' => 'boolean'], + 'int', 'integer' => ['type' => 'integer'], + 'float', 'number' => ['type' => 'number'], + 'bool', 'boolean' => ['type' => 'boolean'], + 'string' => ['type' => 'string'], + 'object' => ['type' => 'object'], 'array' => ['type' => 'array', 'items' => ['type' => 'string']], + 'DateTimeInterface', 'DateTimeImmutable', 'DateTime' => ['type' => 'string', 'format' => 'date-time'], default => ['type' => 'string'], }; } @@ -398,7 +475,7 @@ private function returnsJson(ReflectionMethod $method): bool return $name === 'array' || $name === JsonResponse::class || is_subclass_of($name, JsonResponse::class) - || $name !== Response::class; + || $name !== HttpResponse::class; } private function responseDescription(int $statusCode): string @@ -443,4 +520,161 @@ private function tag(ReflectionClass $controller): string { return preg_replace('/Controller$/', '', $controller->getShortName()) ?: $controller->getShortName(); } + + /** + * @return list + */ + private function tags(ReflectionClass $controller, ReflectionMethod $method): array + { + $tags = []; + + foreach ([$controller, $method] as $reflection) { + foreach ($reflection->getAttributes(Tag::class) as $attribute) { + /** @var Tag $tag */ + $tag = $attribute->newInstance(); + $tags[] = $tag->name; + } + } + + return array_values(array_unique($tags !== [] ? $tags : [$this->tag($controller)])); + } + + /** + * @return list>> + */ + private function security(ReflectionClass $controller, ReflectionMethod $method): array + { + $security = []; + + foreach ([$controller, $method] as $reflection) { + foreach ($reflection->getAttributes(Security::class) as $attribute) { + /** @var Security $item */ + $item = $attribute->newInstance(); + $security[] = [$item->name => $item->scopes]; + } + } + + return $security; + } + + private function summary(ReflectionMethod $method): ?string + { + $attributes = $method->getAttributes(Summary::class); + + if ($attributes === []) { + return null; + } + + /** @var Summary $summary */ + $summary = $attributes[0]->newInstance(); + + return $summary->text; + } + + private function description(ReflectionMethod $method): ?string + { + $attributes = $method->getAttributes(Description::class); + + if ($attributes === []) { + return null; + } + + /** @var Description $description */ + $description = $attributes[0]->newInstance(); + + return $description->text; + } + + private function descriptionFragment(ReflectionParameter $parameter): array + { + $attributes = $parameter->getAttributes(Description::class); + + if ($attributes === []) { + return []; + } + + /** @var Description $description */ + $description = $attributes[0]->newInstance(); + + return ['description' => $description->text]; + } + + private function isRequiredParameter(ReflectionParameter $parameter): bool + { + $schemaAttribute = $this->schemaAttribute($parameter); + + if ($schemaAttribute?->required !== null) { + return $schemaAttribute->required; + } + + return !$parameter->allowsNull() && !$parameter->isDefaultValueAvailable(); + } + + private function schemaAttribute(ReflectionParameter|ReflectionProperty|null $reflection): ?Schema + { + if ($reflection === null) { + return null; + } + + $attributes = $reflection->getAttributes(Schema::class); + + if ($attributes === []) { + return null; + } + + /** @var Schema $schema */ + $schema = $attributes[0]->newInstance(); + + return $schema; + } + + private function applySchemaAttribute(array $schema, ?Schema $attribute): array + { + if ($attribute === null) { + return $schema; + } + + if ($attribute->format !== null) { + $schema['format'] = $attribute->format; + } + + if ($attribute->description !== null) { + $schema['description'] = $attribute->description; + } + + if ($attribute->itemsType !== null && ($schema['type'] ?? null) === 'array') { + $schema['items'] = ['type' => $attribute->itemsType]; + } + + if ($attribute->enum !== []) { + $schema['enum'] = $attribute->enum; + } + + if ($attribute->nullable !== null) { + $schema['nullable'] = $attribute->nullable; + } + + return $schema; + } + + private function dtoProperty(string $class, string $field): ?ReflectionProperty + { + if (!class_exists($class)) { + return null; + } + + $reflection = new ReflectionClass($class); + + return $reflection->hasProperty($field) ? $reflection->getProperty($field) : null; + } + + /** + * @return list + */ + private function normalizeRules(mixed $rules): array + { + $rules = is_array($rules) ? $rules : explode('|', (string) $rules); + + return array_map(static fn (string $rule): string => strtolower(strtok($rule, ':') ?: $rule), $rules); + } } diff --git a/src/OpenApi/OpenApiLivePage.php b/src/OpenApi/OpenApiLivePage.php index 7e703ab..bb16e50 100644 --- a/src/OpenApi/OpenApiLivePage.php +++ b/src/OpenApi/OpenApiLivePage.php @@ -8,13 +8,14 @@ public function render(): string { return <<<'HTML' - + ShiftPHP OpenAPI
-

ShiftPHP OpenAPI

-

Loading OpenAPI document...

+
+
+

ShiftPHP OpenAPI

+

Loading OpenAPI document...

+
+ +
-
+
+
+ + + Download JSON +
+

+
+