diff --git a/.gitignore b/.gitignore index d4e57a3..bb8b519 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ composer.lock .phpunit.result.cache .phpcs-cache + +public/openapi.yaml diff --git a/bin/generate-openapi.php b/bin/generate-openapi.php new file mode 100644 index 0000000..514c9ac --- /dev/null +++ b/bin/generate-openapi.php @@ -0,0 +1,217 @@ + $config */ +$config = $container->get('config'); + +$baseUrl = $config['application']['url'] ?? null; +if (! is_string($baseUrl) || $baseUrl === '') { + fwrite(STDERR, 'Missing the `application.url` config key; cannot set the OpenAPI server URL.' . PHP_EOL); + exit(1); +} + +/** @var array $documentConfig */ +$documentConfig = $config['openapi'] ?? []; + +$outputFile = $documentConfig['output_file'] ?? null; +if (! is_string($outputFile) || $outputFile === '') { + fwrite(STDERR, 'Missing the `openapi.output_file` config key; nowhere to write the document.' . PHP_EOL); + exit(1); +} + +// Relative paths are resolved against the project root, which this script has already chdir'd to. +$outputDirectory = dirname($outputFile); +if (! is_dir($outputDirectory)) { + fwrite(STDERR, sprintf('The output directory "%s" does not exist.', $outputDirectory) . PHP_EOL); + exit(1); +} + +/** + * Tags whose operations are kept out of the published document — see `openapi.exclude_tags`. + * + * @var list $excludedTags + */ +$excludedTags = array_values($documentConfig['exclude_tags'] ?? []); + +$builder = (new Builder())->addSource(new SourceFinder(SOURCE_PATHS)); + +if (isset($documentConfig['openapi_version'])) { + $builder->setVersion($documentConfig['openapi_version']); +} + +if ($excludedTags !== []) { + // swagger-php's PathFilter is an allowlist of tag patterns, so an exclusion is expressed as a + // negative lookahead over the names to drop. Removing those operations orphans the schemas only + // they referenced, which is what CleanUnusedComponents — off by default — then sweeps up. + // + // PathFilter drops whole path items rather than single operations: a path carrying both an + // excluded and a published tag would keep both. Every path in this document has exactly one tag, + // so that does not arise today, but it is worth re-checking if that stops being true. + $keepPattern = sprintf( + '/^(?!(?:%s)$)/', + implode('|', array_map( + static fn (string $tag): string => preg_quote($tag, '/'), + $excludedTags, + )), + ); + + $builder->withGenerator(static function (Generator $generator) use ($keepPattern): void { + $generator->setConfig([ + 'pathFilter' => ['tags' => [$keepPattern]], + 'cleanUnusedComponents' => ['enabled' => true], + ]); + }); +} + +$result = $builder->build(); +$openApi = $result->openApi(); + +if ($openApi === null) { + fwrite(STDERR, 'The scan of ' . implode(', ', SOURCE_PATHS) . ' produced no OpenAPI document.' . PHP_EOL); + exit(1); +} + +/** + * `info.x-generated`: when this document was built. + * + * OpenAPI has no field for build time, so it goes in as a specification extension on the info + * object — the one part of the document that describes the document rather than the API. Absent or + * empty config omits it, which is also how to keep the output reproducible. + */ +$generatedTimezone = $documentConfig['generated_timezone'] ?? null; +$infoExtensions = null; + +if (is_string($generatedTimezone) && $generatedTimezone !== '') { + try { + $generatedAt = new DateTimeImmutable('now', new DateTimeZone($generatedTimezone)); + } catch (DateInvalidTimeZoneException) { + fwrite( + STDERR, + sprintf('Unknown `openapi.generated_timezone` value "%s".', $generatedTimezone) . PHP_EOL, + ); + exit(1); + } + + // ATOM carries the offset, so the timestamp stays unambiguous wherever it is read. + $infoExtensions = ['generated' => $generatedAt->format(DateTimeInterface::ATOM)]; +} + +$openApi->info = new OA\Info( + version: $documentConfig['info']['version'] ?? null, + title: $documentConfig['info']['title'] ?? null, + x: $infoExtensions, +); + +$serverDescription = $documentConfig['server_description'] ?? null; +if ($serverDescription !== null && ! is_string($serverDescription)) { + fwrite(STDERR, 'The `openapi.server_description` config key must be a string.' . PHP_EOL); + exit(1); +} + +// The project's own URL first, then `openapi.servers` — each URL once. +try { + $servers = OpenApiServers::fromConfig($baseUrl, $serverDescription, $documentConfig['servers'] ?? []); +} catch (InvalidArgumentException $exception) { + fwrite(STDERR, $exception->getMessage() . PHP_EOL); + exit(1); +} + +$openApi->servers = array_map( + static fn (array $server): OA\Server => new OA\Server( + url: $server['url'], + description: $server['description'] ?? Undefined::UNDEFINED, + ), + $servers, +); + +if (isset($documentConfig['external_docs']['url'])) { + $openApi->externalDocs = new OA\ExternalDocumentation( + description: $documentConfig['external_docs']['description'] ?? Undefined::UNDEFINED, + url: $documentConfig['external_docs']['url'], + ); +} + +$tags = []; +foreach ($documentConfig['tags'] ?? [] as $name => $description) { + // An excluded tag has no operations left to describe, so declaring it would advertise an empty + // section of the API. + if (in_array((string) $name, $excludedTags, true)) { + continue; + } + + $tags[] = new OA\Tag(name: (string) $name, description: $description); +} + +if ($tags !== []) { + $openApi->tags = $tags; +} + +$securitySchemes = []; +foreach ($documentConfig['security_schemes'] ?? [] as $name => $scheme) { + $securitySchemes[] = new OA\SecurityScheme( + securityScheme: (string) $name, + type: $scheme['type'] ?? null, + name: $scheme['name'] ?? null, + in: $scheme['in'] ?? null, + bearerFormat: $scheme['bearer_format'] ?? null, + scheme: $scheme['scheme'] ?? null, + ); +} + +if ($securitySchemes !== []) { + $openApi->components->securitySchemes = $securitySchemes; +} + +$result->saveAs($outputFile); + +// The scan validates before the root is injected below, so a missing OA\Info is expected here. +$expectedWarning = 'Required @OA\\Info() not found'; + +foreach ($result->warnings() as $warning) { + if (str_contains($warning, $expectedWarning)) { + continue; + } + + fwrite(STDERR, 'warning: ' . $warning . PHP_EOL); +} + +$errors = $result->errors(); +foreach ($errors as $error) { + fwrite(STDERR, 'error: ' . $error . PHP_EOL); +} + +echo sprintf('Wrote %s (%d paths).' . PHP_EOL, $outputFile, count((array) $openApi->paths)); + +exit($errors === [] ? 0 : 1); diff --git a/composer.json b/composer.json index 86b73cf..0c786ef 100644 --- a/composer.json +++ b/composer.json @@ -129,6 +129,7 @@ "development-disable": "laminas-development-mode disable", "development-enable": "laminas-development-mode enable", "development-status": "laminas-development-mode status", + "openapi": "php bin/generate-openapi.php", "post-update-cmd": [ "php ./bin/generate-oauth2-keys.php", "php ./bin/composer-post-install-script.php" diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index ea862bb..eaca8a9 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -42,6 +42,9 @@ return [ 'documentation_url' => 'https://docs.dotkernel.org/api-documentation/v7/tutorials/api-evolution/', ], ], + 'openapi' => [ + 'server_description' => 'Development server', + ], 'authentication' => [ 'private_key' => [ 'key_or_path' => realpath(__DIR__ . '/../../data/oauth/private.key'), diff --git a/config/autoload/openapi.global.php b/config/autoload/openapi.global.php new file mode 100644 index 0000000..33722e6 --- /dev/null +++ b/config/autoload/openapi.global.php @@ -0,0 +1,110 @@ + [ + /** + * Where bin/generate-openapi.php writes the document. + * + * `public/` is what makes the document reachable over HTTP; move it outside if the spec + * should not be served, e.g. `'output_file' => 'data/openapi.yaml'`. + */ + 'output_file' => 'public/openapi.yaml', + /** + * Tags whose endpoints are left out of the generated document. + * + * Use this to stop publishing a module's endpoints: name the tag its operations carry and both + * the paths and the schemas only they referenced will disappear from the generated document. + * The endpoints keep working — this hides them from the document, it is not access control. + * + * Match the tag exactly as the operations declare it, including spaces; the name is quoted + * before it reaches the filter, so no escaping is needed here. Names listed below are also + * dropped from the `tags` block, since an excluded tag has no operations left to describe. + * + * Example — keep the simulator and the card on-ramp out of the public document: + * + * 'exclude_tags' => [ + * 'Simulator', + * 'Funding', + * ], + * + * Regenerate with `composer openapi` afterward; the run reports the remaining path count. + */ + 'exclude_tags' => [], + /** + * Servers published after the project's own URL. + * + * The first server in the generated document is always `application.url`, labelled with + * `openapi.server_description`. The entries below follow it in this order, each with a `url` and an + * optional `description`. A URL already in the list — the project's own included — is written once, + * and the first occurrence keeps its description. + * + * Example — also publish a sandbox: + * + * 'servers' => [ + * ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + * ], + * + * An entry without a non-empty `url` fails `composer openapi`. + */ + 'servers' => [], + /** + * Timezone for the `info.x-generated` build timestamp, as an IANA identifier. + * + * OpenAPI has no field for when a document was built, so it is written as a specification + * extension on the info object — the part of the document that describes the document rather + * than the API. The value is ISO-8601 with the offset included, e.g. + * `2026-09-08T10:39:29-04:00`, so it stays unambiguous across daylight saving. + * + * Set it to null to leave the field out. That also makes the output byte-identical between + * runs, which is worth having if the document is committed — a timestamp changes on every + * generation and shows up as a diff even when no endpoint did. + */ + 'generated_timezone' => 'UTC', + 'openapi_version' => '3.1.0', + 'info' => [ + 'title' => 'Dotkernel API', + 'version' => '1.0', + ], + 'external_docs' => [ + 'description' => 'Dotkernel API documentation', + 'url' => 'https://docs.dotkernel.org/api-documentation/', + ], + 'tags' => [ + 'AccessToken' => 'OAuth2 token issue and refresh.', + 'ActivateUser' => 'Administrative activation and deactivation of users.', + 'Admin' => 'Administrator records and the administrator\'s own account.', + 'AdminRole' => 'Administrator role catalogue.', + 'ErrorReport' => 'Error reporting for third-party clients.', + 'Home' => 'Application root.', + 'RecoverIdentity' => 'Recovery of a forgotten sign-in identity.', + 'ResetPassword' => 'Password reset request and completion.', + 'User' => 'User records and the caller\'s own account.', + 'UserAvatar' => 'User and account avatars.', + 'UserRole' => 'User role catalogue.', + ], + 'security_schemes' => [ + 'AuthToken' => [ + 'type' => 'http', + 'bearer_format' => 'JWT', + 'scheme' => 'bearer', + ], + 'ErrorReportingToken' => [ + 'type' => 'apiKey', + 'name' => 'Error-Reporting-Token', + 'in' => 'header', + ], + ], + ], +]; diff --git a/src/Admin/src/OpenAPI.php b/src/Admin/src/OpenAPI.php index 7ceb480..7ca94ef 100644 --- a/src/Admin/src/OpenAPI.php +++ b/src/Admin/src/OpenAPI.php @@ -19,7 +19,6 @@ use Core\Admin\Entity\AdminRole; use Core\Admin\Enum\AdminRoleEnum; use Core\Admin\Enum\AdminStatusEnum; -use DateTimeImmutable; use Fig\Http\Message\StatusCodeInterface; use OpenApi\Attributes as OA; @@ -460,8 +459,16 @@ type: 'object', ), ), - new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), - new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: 'created', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), + new OA\Property( + property: 'updated', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), new OA\Property( property: '_links', properties: [ diff --git a/src/App/src/Helper/OpenApiServers.php b/src/App/src/Helper/OpenApiServers.php new file mode 100644 index 0000000..bec1ce3 --- /dev/null +++ b/src/App/src/Helper/OpenApiServers.php @@ -0,0 +1,66 @@ + + * @throws InvalidArgumentException When `openapi.servers` or one of its entries is malformed. + */ + public static function fromConfig(string $baseUrl, ?string $baseDescription, mixed $additionalServers): array + { + if ($baseUrl === '') { + throw new InvalidArgumentException('The project URL must be a non-empty string.'); + } + + if (! is_array($additionalServers)) { + throw new InvalidArgumentException('The `openapi.servers` config key must be an array.'); + } + + $servers = [$baseUrl => ['url' => $baseUrl, 'description' => $baseDescription]]; + + foreach ($additionalServers as $index => $server) { + $url = is_array($server) ? ($server['url'] ?? null) : null; + if (! is_string($url) || $url === '') { + throw new InvalidArgumentException(sprintf( + 'The `openapi.servers` entry "%s" needs a non-empty string `url`.', + $index, + )); + } + + $description = $server['description'] ?? null; + if ($description !== null && ! is_string($description)) { + throw new InvalidArgumentException(sprintf( + 'The `openapi.servers` entry "%s" has a `description` that is not a string.', + $index, + )); + } + + if (array_key_exists($url, $servers)) { + continue; + } + + $servers[$url] = ['url' => $url, 'description' => $description]; + } + + return array_values($servers); + } +} diff --git a/src/App/src/OpenAPI.php b/src/App/src/OpenAPI.php index 5a3588b..793e536 100644 --- a/src/App/src/OpenAPI.php +++ b/src/App/src/OpenAPI.php @@ -9,33 +9,6 @@ use Fig\Http\Message\StatusCodeInterface; use OpenApi\Attributes as OA; -#[OA\OpenApi( - info: new OA\Info(version: '1.0', title: 'Dotkernel API'), - servers: [ - new OA\Server(url: 'http://api.dotkernel.localhost', description: 'Local development server'), - ], - externalDocs: new OA\ExternalDocumentation( - description: 'Dotkernel API documentation', - url: 'https://docs.dotkernel.org/api-documentation/', - ), - components: new OA\Components( - securitySchemes: [ - new OA\SecurityScheme( - securityScheme: 'AuthToken', - type: 'http', - bearerFormat: 'JWT', - scheme: 'bearer', - ), - new OA\SecurityScheme( - securityScheme: 'ErrorReportingToken', - type: 'apiKey', - name: 'Error-Reporting-Token', - in: 'header', - ), - ], - ), -)] - /** * @see GetIndexResourceHandler::handle() */ @@ -104,7 +77,7 @@ #[OA\Schema( schema: 'HomeMessage', properties: [ - new OA\Property(property: 'message', type: 'string', default: 'Dotkernel API version 5'), + new OA\Property(property: 'message', type: 'string', default: 'Dotkernel API version 7'), ], type: 'object', )] @@ -137,6 +110,32 @@ type: 'object', )] +#[OA\Schema( + schema: 'DateTimeObject', + title: 'DateTimeObject', + description: 'A timestamp as this API puts it on the wire. Entities hand their DateTimeImmutable ' + . 'straight to the serializer, so a timestamp arrives as PHP\'s own object form rather than as an ' + . 'ISO-8601 string. `date` carries microsecond precision and no offset; the zone is named ' + . 'separately in `timezone`.', + properties: [ + new OA\Property( + property: 'date', + description: 'Local date and time in the named zone, to microseconds', + type: 'string', + example: '2026-09-08 11:15:09.421498', + ), + new OA\Property( + property: 'timezone_type', + description: 'How `timezone` is expressed: 1 offset, 2 abbreviation, 3 identifier', + type: 'integer', + example: 3, + enum: [1, 2, 3], + ), + new OA\Property(property: 'timezone', type: 'string', example: 'UTC'), + ], + type: 'object', +)] + #[OA\Schema( schema: 'Collection', description: 'Base collection providing common structure to be extended by entity-specific collections', diff --git a/src/User/src/OpenAPI.php b/src/User/src/OpenAPI.php index 56a45dc..97b8a7d 100644 --- a/src/User/src/OpenAPI.php +++ b/src/User/src/OpenAPI.php @@ -38,7 +38,6 @@ use Core\User\Enum\UserResetPasswordStatusEnum; use Core\User\Enum\UserRoleEnum; use Core\User\Enum\UserStatusEnum; -use DateTimeImmutable; use Fig\Http\Message\StatusCodeInterface; use OpenApi\Attributes as OA; @@ -1138,8 +1137,16 @@ ref: '#/components/schemas/UserResetPassword', ), ), - new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), - new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: 'created', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), + new OA\Property( + property: 'updated', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), new OA\Property( property: '_links', properties: [ @@ -1174,8 +1181,16 @@ example: 'https://example.com/uploads/user/1234abcd-abcd-4321-12ab-123456abcdef/' . 'avatar-1234abcd-abcd-4321-12ab-123456abcdef.jpg', ), - new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), - new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: 'created', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), + new OA\Property( + property: 'updated', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), new OA\Property( property: '_links', properties: [ @@ -1207,8 +1222,16 @@ new OA\Property(property: 'firstName', type: 'string'), new OA\Property(property: 'lastName', type: 'string'), new OA\Property(property: 'email', type: 'string'), - new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), - new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: 'created', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), + new OA\Property( + property: 'updated', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), ], type: 'object', )] @@ -1220,11 +1243,19 @@ schema: 'UserResetPassword', properties: [ new OA\Property(property: 'id', type: 'string', example: '1234abcd-abcd-4321-12ab-123456abcdef'), - new OA\Property(property: 'expires', type: 'object', example: new DateTimeImmutable()), + new OA\Property(property: 'expires', ref: '#/components/schemas/DateTimeObject'), new OA\Property(property: 'hash', type: 'string'), new OA\Property(property: 'status', type: 'string', example: UserResetPasswordStatusEnum::Requested), - new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()), - new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()), + new OA\Property( + property: 'created', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), + new OA\Property( + property: 'updated', + ref: '#/components/schemas/DateTimeObject', + nullable: true, + ), new OA\Property( property: '_links', properties: [ diff --git a/test/Unit/App/Helper/OpenApiServersTest.php b/test/Unit/App/Helper/OpenApiServersTest.php new file mode 100644 index 0000000..2687943 --- /dev/null +++ b/test/Unit/App/Helper/OpenApiServersTest.php @@ -0,0 +1,125 @@ +assertSame( + [['url' => self::BASE_URL, 'description' => 'Production']], + OpenApiServers::fromConfig(self::BASE_URL, 'Production', []), + ); + } + + public function testTheProjectUrlMayHaveNoDescription(): void + { + $this->assertSame( + [['url' => self::BASE_URL, 'description' => null]], + OpenApiServers::fromConfig(self::BASE_URL, null, []), + ); + } + + public function testAdditionalServersFollowTheProjectUrlInTheirConfiguredOrder(): void + { + $servers = OpenApiServers::fromConfig(self::BASE_URL, 'Production', [ + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ['url' => 'https://staging.example.com'], + ]); + + $this->assertSame( + [ + ['url' => self::BASE_URL, 'description' => 'Production'], + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ['url' => 'https://staging.example.com', 'description' => null], + ], + $servers, + ); + } + + public function testTheProjectUrlIsListedOnceAndKeepsItsOwnDescription(): void + { + $servers = OpenApiServers::fromConfig(self::BASE_URL, 'Production', [ + ['url' => self::BASE_URL, 'description' => 'Duplicate'], + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ]); + + $this->assertSame( + [ + ['url' => self::BASE_URL, 'description' => 'Production'], + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ], + $servers, + ); + } + + public function testADuplicateWithinTheAdditionalServersIsListedOnce(): void + { + $servers = OpenApiServers::fromConfig(self::BASE_URL, 'Production', [ + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox again'], + ]); + + $this->assertSame( + [ + ['url' => self::BASE_URL, 'description' => 'Production'], + ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'], + ], + $servers, + ); + } + + public function testAnEmptyProjectUrlIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + OpenApiServers::fromConfig('', 'Production', []); + } + + public function testServersThatAreNotAnArrayAreRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `openapi.servers` config key must be an array.'); + + OpenApiServers::fromConfig(self::BASE_URL, 'Production', 'https://sandbox.example.com'); + } + + /** + * @return iterable + */ + public static function malformedEntryProvider(): iterable + { + yield 'not an array' => ['https://sandbox.example.com']; + yield 'missing url' => [['description' => 'Sandbox']]; + yield 'empty url' => [['url' => '', 'description' => 'Sandbox']]; + yield 'url not a string' => [['url' => 42, 'description' => 'Sandbox']]; + } + + #[DataProvider('malformedEntryProvider')] + public function testAnEntryWithoutAUrlIsRejected(mixed $entry): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `openapi.servers` entry "0" needs a non-empty string `url`.'); + + OpenApiServers::fromConfig(self::BASE_URL, 'Production', [$entry]); + } + + public function testADescriptionThatIsNotAStringIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `openapi.servers` entry "0" has a `description` that is not a string.'); + + OpenApiServers::fromConfig(self::BASE_URL, 'Production', [ + ['url' => 'https://sandbox.example.com', 'description' => ['Sandbox']], + ]); + } +}