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