diff --git a/README.md b/README.md index 8b7f5e3..2fed748 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,17 @@ Run local quality checks: ./shift qa ``` -`shift lint` checks PHP syntax and basic file hygiene. `shift qa` runs Composer validation, lint checks, the test suite, and route listing. +`shift lint` checks PHP syntax and basic file hygiene. `shift qa` runs Composer validation, lint checks, the test suite, route listing, and OpenAPI generation. + +Generate OpenAPI documentation from registered routes: + +```sh +./shift openapi +./shift openapi --output=docs/openapi.json +./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. Run the example module command: diff --git a/REFACTORING.md b/REFACTORING.md index 330dbda..25e3a2e 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -41,6 +41,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Request id lifecycle with generated `X-Request-Id` response headers and log context. - [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] 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 77435ce..478c04b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -283,6 +283,7 @@

Architecture

  • Modules
  • Service Container
  • Console Commands
  • +
  • OpenAPI
  • Database

    @@ -687,6 +688,23 @@

    Console Commands

    ./shift sync-billing +
    +

    OpenAPI

    +

    The OpenAPI command generates an OpenAPI 3.0 JSON document from the routes registered in loaded modules.

    + +
    ./shift openapi
    +./shift openapi --output=docs/openapi.json
    +./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.

    + +
    ./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.

    +
    +

    Database

    ShiftPHP uses native PDO and registers Shift\Database\Database and the db alias lazily in the service container.

    @@ -815,7 +833,7 @@

    Quality Checks

    ./shift lint
     ./shift qa
    -

    shift lint checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. shift qa runs Composer validation, lint checks, the test suite, and the route list command.

    +

    shift lint checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. shift qa runs Composer validation, lint checks, the test suite, route listing, and OpenAPI generation.

    ./shift help lint
     ./shift help qa
    @@ -837,7 +855,7 @@

    Testing

    composer test
     ./shift test
    -

    The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, ./shift qa runs the same kind of pre-PR quality gate.

    +

    The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, ./shift qa runs the same kind of pre-PR quality gate and also checks OpenAPI generation.

    diff --git a/src/Console/Commands/OpenApi.php b/src/Console/Commands/OpenApi.php new file mode 100644 index 0000000..70b606b --- /dev/null +++ b/src/Console/Commands/OpenApi.php @@ -0,0 +1,134 @@ +load()->registerRoutes($router); + + $document = (new OpenApiGenerator())->generate($router); + $json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if ($json === false) { + (new Cli())->error('Unable to encode OpenAPI document.'); + exit(1); + } + + $outputPath = $this->outputPath($args); + $live = $this->hasOption($args, '--live'); + + if ($outputPath === null && !$live) { + echo $json . PHP_EOL; + return; + } + + if ($outputPath !== null) { + $this->writeFile($outputPath, $json . PHP_EOL); + (new Cli())->success('OpenAPI document written to ' . $outputPath); + } + + if ($live) { + $this->serveLiveDocumentation($json, $args); + } + } + + public function getHelp(): string + { + return 'Usage: ./shift openapi [--output=docs/openapi.json] [--live] [--host=127.0.0.1] [--port=8088]'; + } + + public function getDescription(): string + { + return 'Generate an OpenAPI JSON document from registered routes.'; + } + + private function outputPath(array $args): ?string + { + foreach ($args as $arg) { + if (!is_string($arg) || !str_starts_with($arg, '--output=')) { + continue; + } + + $path = trim(substr($arg, 9)); + + if ($path === '') { + return null; + } + + return str_starts_with($path, '/') ? $path : APP_ROOT . '/' . $path; + } + + return null; + } + + private function serveLiveDocumentation(string $json, array $args): void + { + $host = $this->optionValue($args, '--host=') ?? '127.0.0.1'; + $port = $this->optionValue($args, '--port=') ?? '8088'; + $root = sys_get_temp_dir() . '/shift-openapi-live-' . bin2hex(random_bytes(6)); + + mkdir($root, 0775, true); + $this->writeFile($root . '/openapi.json', $json . PHP_EOL); + $this->writeFile($root . '/index.html', (new OpenApiLivePage())->render()); + + $url = 'http://' . $host . ':' . $port; + $cli = new Cli(); + $cli->info('OpenAPI live documentation: ' . $url); + $cli->debug('Press Ctrl+C to stop the server.'); + + passthru(escapeshellarg(PHP_BINARY) . ' -S ' . escapeshellarg($host . ':' . $port) . ' -t ' . escapeshellarg($root), $exitCode); + + if ($exitCode !== 0) { + $cli->error('OpenAPI live server stopped with exit code ' . $exitCode . '.'); + exit($exitCode); + } + } + + private function writeFile(string $path, string $contents): void + { + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($path, $contents); + } + + private function hasOption(array $args, string $option): bool + { + foreach ($args as $arg) { + if ($arg === $option) { + return true; + } + } + + return false; + } + + private function optionValue(array $args, string $prefix): ?string + { + foreach ($args as $arg) { + if (!is_string($arg) || !str_starts_with($arg, $prefix)) { + continue; + } + + $value = trim(substr($arg, strlen($prefix))); + + return $value !== '' ? $value : null; + } + + return null; + } +} diff --git a/src/Console/Quality/QualityChecks.php b/src/Console/Quality/QualityChecks.php index bfc11d8..ffdd9c4 100644 --- a/src/Console/Quality/QualityChecks.php +++ b/src/Console/Quality/QualityChecks.php @@ -32,6 +32,7 @@ public function qa(): array $this->fileHygiene(), $this->testSuite(), $this->routeList(), + $this->openApiDocument(), ]; } @@ -93,6 +94,38 @@ public function routeList(): CheckResult return $this->runShellCheck('Route list', $command, './shift route:list passed'); } + 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'); + } + + return CheckResult::ok('OpenAPI document', './shift openapi passed'); + } + private function runShellCheck(string $name, string $command, string $successDetails): CheckResult { $previousDirectory = getcwd(); diff --git a/src/OpenApi/OpenApiGenerator.php b/src/OpenApi/OpenApiGenerator.php new file mode 100644 index 0000000..aed6b6d --- /dev/null +++ b/src/OpenApi/OpenApiGenerator.php @@ -0,0 +1,446 @@ +getRoutes() as $route) { + $path = $this->openApiPath($route->getPath()); + $method = strtolower($route->getMethod()); + + $paths[$path][$method] = $this->operation($route); + } + + ksort($paths); + + foreach ($paths as $path => $operations) { + ksort($operations); + $paths[$path] = $operations; + } + + return [ + 'openapi' => '3.0.3', + 'info' => [ + 'title' => 'ShiftPHP API', + 'version' => getenv('APP_VERSION') ?: '0.1.0', + ], + 'paths' => $paths, + ]; + } + + private function operation(Route $route): array + { + [$controllerClass, $methodName] = $route->getHandler(); + $method = new ReflectionMethod($controllerClass, $methodName); + $controller = new ReflectionClass($controllerClass); + $statusCode = $this->statusCode($method); + $operation = [ + 'operationId' => $this->operationId($controller, $method), + 'tags' => [$this->tag($controller)], + 'responses' => [ + (string) $statusCode => $this->response($method, $statusCode), + ], + ]; + + $parameters = $this->parameters($route, $method); + + if ($parameters !== []) { + $operation['parameters'] = $parameters; + } + + $requestBody = $this->requestBody($method); + + if ($requestBody !== null) { + $operation['requestBody'] = $requestBody; + } + + return $operation; + } + + private function response(ReflectionMethod $method, int $statusCode): array + { + $response = [ + 'description' => $this->responseDescription($statusCode), + ]; + + $headers = $this->responseHeaders($method); + + if ($headers !== []) { + $response['headers'] = $headers; + } + + if ($statusCode !== 204 && $this->returnsJson($method)) { + $response['content'] = [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ]; + } + + return $response; + } + + private function responseHeaders(ReflectionMethod $method): array + { + $headers = []; + + foreach ($method->getAttributes(Header::class) as $attribute) { + /** @var Header $header */ + $header = $attribute->newInstance(); + $headers[$header->name] = [ + 'description' => $header->value, + 'schema' => [ + 'type' => 'string', + 'example' => $header->value, + ], + ]; + } + + ksort($headers); + + return $headers; + } + + private function parameters(Route $route, ReflectionMethod $method): array + { + $parameters = []; + $pathParameterNames = $this->pathParameterNames($route->getPath()); + $usedPathParameters = []; + + foreach ($method->getParameters() as $parameter) { + $pathParameter = $this->pathParameterName($parameter); + + if ($pathParameter !== null || in_array($parameter->getName(), $pathParameterNames, true)) { + $name = $pathParameter ?? $parameter->getName(); + $parameters[] = $this->parameter($name, 'path', $parameter, true); + $usedPathParameters[] = $name; + continue; + } + + $queryParameter = $this->queryParameterName($parameter); + + if ($queryParameter !== null) { + $parameters[] = $this->parameter($queryParameter, 'query', $parameter, !$parameter->allowsNull() && !$parameter->isDefaultValueAvailable()); + } + } + + foreach (array_diff($pathParameterNames, $usedPathParameters) as $name) { + $parameters[] = [ + 'name' => $name, + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + ]; + } + + usort($parameters, static function (array $left, array $right): int { + return [$left['in'], $left['name']] <=> [$right['in'], $right['name']]; + }); + + return $parameters; + } + + private function requestBody(ReflectionMethod $method): ?array + { + $properties = []; + $required = []; + + foreach ($method->getParameters() as $parameter) { + $bodyDto = $this->bodyDtoClass($parameter); + + if ($bodyDto !== null) { + return [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => $this->schemaForDto($bodyDto), + ], + ], + ]; + } + + $bodyKey = $this->bodyKey($parameter); + + if ($bodyKey === null) { + continue; + } + + $properties[$bodyKey] = $this->schemaForParameter($parameter); + + if (!$parameter->allowsNull() && !$parameter->isDefaultValueAvailable()) { + $required[] = $bodyKey; + } + } + + if ($properties === []) { + return null; + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if ($required !== []) { + $schema['required'] = $required; + } + + return [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => $schema, + ], + ], + ]; + } + + private function schemaForDto(string $class): array + { + $schema = [ + 'type' => 'object', + 'properties' => [], + ]; + $required = []; + + if (is_subclass_of($class, RequestDto::class)) { + foreach ($class::rules() as $field => $rules) { + $schema['properties'][$field] = $this->schemaForRules($rules); + + if ($this->rulesRequireField($rules)) { + $required[] = $field; + } + } + } + + if ($required !== []) { + $schema['required'] = $required; + } + + return $schema; + } + + private function schemaForRules(mixed $rules): 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']; + } + + if (in_array('numeric', $rules, true) || in_array('float', $rules, true)) { + return ['type' => 'number']; + } + + if (in_array('boolean', $rules, true) || in_array('bool', $rules, true)) { + return ['type' => 'boolean']; + } + + if (in_array('array', $rules, true)) { + return ['type' => 'array', 'items' => ['type' => 'string']]; + } + + return ['type' => 'string']; + } + + private function rulesRequireField(mixed $rules): bool + { + $rules = is_array($rules) ? $rules : explode('|', (string) $rules); + + return in_array('required', array_map('strtolower', $rules), true); + } + + private function bodyDtoClass(ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(BodyDto::class); + + if ($attributes !== []) { + /** @var BodyDto $bodyDto */ + $bodyDto = $attributes[0]->newInstance(); + + if (is_string($bodyDto->class) && $bodyDto->class !== '') { + return $bodyDto->class; + } + } + + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin() && is_subclass_of($type->getName(), RequestDto::class)) { + return $type->getName(); + } + + return null; + } + + private function bodyKey(ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(Body::class); + + if ($attributes === []) { + return null; + } + + /** @var Body $body */ + $body = $attributes[0]->newInstance(); + + return $body->key ?? $parameter->getName(); + } + + private function pathParameterName(ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(PathParam::class); + + if ($attributes === []) { + return null; + } + + /** @var PathParam $path */ + $path = $attributes[0]->newInstance(); + + return $path->name ?? $parameter->getName(); + } + + private function queryParameterName(ReflectionParameter $parameter): ?string + { + $attributes = $parameter->getAttributes(QueryParam::class); + + if ($attributes === []) { + return null; + } + + /** @var QueryParam $query */ + $query = $attributes[0]->newInstance(); + + return $query->name ?? $parameter->getName(); + } + + private function parameter(string $name, string $in, ReflectionParameter $parameter, bool $required): array + { + return [ + 'name' => $name, + 'in' => $in, + 'required' => $required, + 'schema' => $this->schemaForParameter($parameter), + ]; + } + + private function schemaForParameter(ReflectionParameter $parameter): array + { + $type = $parameter->getType(); + + if (!$type instanceof ReflectionNamedType) { + return ['type' => 'string']; + } + + return $this->schemaForPhpType($type->getName()); + } + + private function schemaForPhpType(string $type): array + { + return match (ltrim($type, '\\')) { + 'int' => ['type' => 'integer'], + 'float' => ['type' => 'number'], + 'bool' => ['type' => 'boolean'], + 'array' => ['type' => 'array', 'items' => ['type' => 'string']], + default => ['type' => 'string'], + }; + } + + private function statusCode(ReflectionMethod $method): int + { + $attributes = $method->getAttributes(Status::class); + + if ($attributes === []) { + return 200; + } + + /** @var Status $status */ + $status = $attributes[0]->newInstance(); + + return $status->code; + } + + private function returnsJson(ReflectionMethod $method): bool + { + $type = $method->getReturnType(); + + if (!$type instanceof ReflectionNamedType) { + return true; + } + + $name = ltrim($type->getName(), '\\'); + + return $name === 'array' + || $name === JsonResponse::class + || is_subclass_of($name, JsonResponse::class) + || $name !== Response::class; + } + + private function responseDescription(int $statusCode): string + { + return match ($statusCode) { + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 204 => 'No Content', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 422 => 'Unprocessable Entity', + 500 => 'Internal Server Error', + default => 'Response', + }; + } + + /** + * @return list + */ + private function pathParameterNames(string $path): array + { + preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_]*)}/', $path, $matches); + + return $matches[1] ?? []; + } + + private function openApiPath(string $path): string + { + return preg_replace('/\{([a-zA-Z_][a-zA-Z0-9_]*)}/', '{$1}', $path) ?? $path; + } + + private function operationId(ReflectionClass $controller, ReflectionMethod $method): string + { + return lcfirst($controller->getShortName()) . ucfirst($method->getName()); + } + + private function tag(ReflectionClass $controller): string + { + return preg_replace('/Controller$/', '', $controller->getShortName()) ?: $controller->getShortName(); + } +} diff --git a/src/OpenApi/OpenApiLivePage.php b/src/OpenApi/OpenApiLivePage.php new file mode 100644 index 0000000..7e703ab --- /dev/null +++ b/src/OpenApi/OpenApiLivePage.php @@ -0,0 +1,278 @@ + + + + + + ShiftPHP OpenAPI + + + +
    +

    ShiftPHP OpenAPI

    +

    Loading OpenAPI document...

    +
    +
    + + + + +HTML; + } +} diff --git a/tests/Feature/OpenApiTest.php b/tests/Feature/OpenApiTest.php new file mode 100644 index 0000000..00c7964 --- /dev/null +++ b/tests/Feature/OpenApiTest.php @@ -0,0 +1,83 @@ + function (): void { + $registry = CommandRegistry::default(); + + assertSameValue(OpenApi::class, $registry->find('openapi'), 'Registry should expose openapi command.'); + assertSameValue(OpenApi::class, $registry->find('api:docs'), 'Registry should resolve openapi alias.'); + }, + 'openapi generator documents route attributes' => function (): void { + $router = new Router(); + (new AttributeRouteLoader())->load($router, [ + TestAttributeController::class, + DtoController::class, + ]); + + $document = (new OpenApiGenerator())->generate($router); + + assertSameValue('3.0.3', $document['openapi'], 'OpenAPI version should be present.'); + assertArrayHasKeyValue('operationId', 'testAttributeControllerApi', $document['paths']['/test/api/{argument}']['get'], 'Path operation should include operation id.'); + + $getOperation = $document['paths']['/test/api/{argument}']['get']; + assertSameValue('path', $getOperation['parameters'][0]['in'], 'Path parameter should be documented.'); + assertSameValue('argument', $getOperation['parameters'][0]['name'], 'Path parameter name should be documented.'); + assertSameValue(true, $getOperation['parameters'][0]['required'], 'Path parameter should be required.'); + assertSameValue('query', $getOperation['parameters'][1]['in'], 'Query parameter should be documented.'); + assertSameValue('include', $getOperation['parameters'][1]['name'], 'Query parameter name should be documented.'); + + $createdOperation = $document['paths']['/test/created']['post']; + assertArrayHasKeyValue('description', 'Created', $createdOperation['responses']['201'], 'Status attribute should set response code.'); + assertSameValue('created', $createdOperation['responses']['201']['headers']['X-Test']['schema']['example'], 'Header attribute should be documented.'); + assertSameValue('string', $createdOperation['requestBody']['content']['application/json']['schema']['properties']['name']['type'], 'Body parameter should be documented.'); + + $dtoOperation = $document['paths']['/dto/users']['post']; + $dtoSchema = $dtoOperation['requestBody']['content']['application/json']['schema']; + assertSameValue('string', $dtoSchema['properties']['email']['type'], 'DTO string field should be documented.'); + assertSameValue('integer', $dtoSchema['properties']['age']['type'], 'DTO int field should be documented.'); + assertSameValue(['email', 'age'], $dtoSchema['required'], 'DTO required fields should be documented.'); + }, + 'openapi command writes output file' => function (): void { + $root = sys_get_temp_dir() . '/shift-openapi-' . bin2hex(random_bytes(6)); + mkdir($root, 0775, true); + $output = $root . '/openapi.json'; + + try { + ob_start(); + (new OpenApi())->execute('--output=' . $output); + $message = ob_get_clean(); + + assertFileExists($output, 'OpenAPI command should write the requested output file.'); + assertStringContains('OpenAPI document written', $message, 'OpenAPI command should print success message.'); + + $document = json_decode((string) file_get_contents($output), true); + assertSameValue('3.0.3', $document['openapi'] ?? null, 'Written OpenAPI JSON should be valid.'); + assertSameValue('OK', $document['paths']['/health']['get']['responses']['200']['description'] ?? null, 'Written OpenAPI JSON should include module routes.'); + } finally { + removeDirectory($root); + } + }, + 'openapi live page renders swagger-like shell' => function (): void { + $html = (new OpenApiLivePage())->render(); + + assertStringContains('ShiftPHP OpenAPI', $html, 'Live page should include the OpenAPI title.'); + assertStringContains("fetch('openapi.json')", $html, 'Live page should load generated OpenAPI JSON.'); + assertStringContains('Responses', $html, 'Live page should render response sections.'); + }, + 'openapi help documents live server options' => function (): void { + ob_start(); + (new \Console\Commands\Help())->execute('openapi'); + $output = ob_get_clean(); + + assertStringContains('--live', $output, 'OpenAPI help should document live mode.'); + assertStringContains('--host=127.0.0.1', $output, 'OpenAPI help should document host option.'); + assertStringContains('--port=8088', $output, 'OpenAPI help should document port option.'); + }, +];