Skip to content

Commit 8556463

Browse files
committed
Issue #558: Implement bin/generate-openapi.php to generate OpenAPI
Signed-off-by: alexmerlin <alex.merlin.1985@gmail.com>
1 parent 669bdaf commit 8556463

8 files changed

Lines changed: 475 additions & 41 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ composer.lock
3939

4040
.phpunit.result.cache
4141
.phpcs-cache
42+
43+
public/openapi.yaml

‎bin/generate-openapi.php‎

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
3+
/**
4+
* Generates the OpenAPI document from the OA attributes under src/, to the path named by
5+
* `openapi.output_file`.
6+
*
7+
* The document root — info, servers, external docs, security schemes — is not declared with
8+
* attributes. PHP attribute arguments must be constant expressions, so they cannot read the API
9+
* base URL out of the local autoload config; the root is assembled here from the `openapi` and
10+
* `application` config keys instead and injected after the scan.
11+
*
12+
* `openapi.exclude_tags` keeps a module's endpoints out of the published document entirely.
13+
*
14+
* `openapi.servers` lists servers published after the project's own URL; see Api\App\Helper\OpenApiServers.
15+
*/
16+
17+
declare(strict_types=1);
18+
19+
use Api\App\Helper\OpenApiServers;
20+
use OpenApi\Attributes as OA;
21+
use OpenApi\Builder;
22+
use OpenApi\Generator;
23+
use OpenApi\Undefined;
24+
use OpenApi\Utils\SourceFinder;
25+
use Psr\Container\ContainerInterface;
26+
27+
chdir(dirname(__DIR__));
28+
29+
require 'vendor/autoload.php';
30+
31+
const SOURCE_PATHS = ['src'];
32+
33+
/** @var ContainerInterface $container */
34+
$container = require 'config/container.php';
35+
36+
/** @var array<string, mixed> $config */
37+
$config = $container->get('config');
38+
39+
$baseUrl = $config['application']['url'] ?? null;
40+
if (! is_string($baseUrl) || $baseUrl === '') {
41+
fwrite(STDERR, 'Missing the `application.url` config key; cannot set the OpenAPI server URL.' . PHP_EOL);
42+
exit(1);
43+
}
44+
45+
/** @var array<string, mixed> $documentConfig */
46+
$documentConfig = $config['openapi'] ?? [];
47+
48+
$outputFile = $documentConfig['output_file'] ?? null;
49+
if (! is_string($outputFile) || $outputFile === '') {
50+
fwrite(STDERR, 'Missing the `openapi.output_file` config key; nowhere to write the document.' . PHP_EOL);
51+
exit(1);
52+
}
53+
54+
// Relative paths are resolved against the project root, which this script has already chdir'd to.
55+
$outputDirectory = dirname($outputFile);
56+
if (! is_dir($outputDirectory)) {
57+
fwrite(STDERR, sprintf('The output directory "%s" does not exist.', $outputDirectory) . PHP_EOL);
58+
exit(1);
59+
}
60+
61+
/**
62+
* Tags whose operations are kept out of the published document — see `openapi.exclude_tags`.
63+
*
64+
* @var list<non-empty-string> $excludedTags
65+
*/
66+
$excludedTags = array_values($documentConfig['exclude_tags'] ?? []);
67+
68+
$builder = (new Builder())->addSource(new SourceFinder(SOURCE_PATHS));
69+
70+
if (isset($documentConfig['openapi_version'])) {
71+
$builder->setVersion($documentConfig['openapi_version']);
72+
}
73+
74+
if ($excludedTags !== []) {
75+
// swagger-php's PathFilter is an allowlist of tag patterns, so an exclusion is expressed as a
76+
// negative lookahead over the names to drop. Removing those operations orphans the schemas only
77+
// they referenced, which is what CleanUnusedComponents — off by default — then sweeps up.
78+
//
79+
// PathFilter drops whole path items rather than single operations: a path carrying both an
80+
// excluded and a published tag would keep both. Every path in this document has exactly one tag,
81+
// so that does not arise today, but it is worth re-checking if that stops being true.
82+
$keepPattern = sprintf(
83+
'/^(?!(?:%s)$)/',
84+
implode('|', array_map(
85+
static fn (string $tag): string => preg_quote($tag, '/'),
86+
$excludedTags,
87+
)),
88+
);
89+
90+
$builder->withGenerator(static function (Generator $generator) use ($keepPattern): void {
91+
$generator->setConfig([
92+
'pathFilter' => ['tags' => [$keepPattern]],
93+
'cleanUnusedComponents' => ['enabled' => true],
94+
]);
95+
});
96+
}
97+
98+
$result = $builder->build();
99+
$openApi = $result->openApi();
100+
101+
if ($openApi === null) {
102+
fwrite(STDERR, 'The scan of ' . implode(', ', SOURCE_PATHS) . ' produced no OpenAPI document.' . PHP_EOL);
103+
exit(1);
104+
}
105+
106+
/**
107+
* `info.x-generated`: when this document was built.
108+
*
109+
* OpenAPI has no field for build time, so it goes in as a specification extension on the info
110+
* object — the one part of the document that describes the document rather than the API. Absent or
111+
* empty config omits it, which is also how to keep the output reproducible.
112+
*/
113+
$generatedTimezone = $documentConfig['generated_timezone'] ?? null;
114+
$infoExtensions = null;
115+
116+
if (is_string($generatedTimezone) && $generatedTimezone !== '') {
117+
try {
118+
$generatedAt = new DateTimeImmutable('now', new DateTimeZone($generatedTimezone));
119+
} catch (DateInvalidTimeZoneException) {
120+
fwrite(
121+
STDERR,
122+
sprintf('Unknown `openapi.generated_timezone` value "%s".', $generatedTimezone) . PHP_EOL,
123+
);
124+
exit(1);
125+
}
126+
127+
// ATOM carries the offset, so the timestamp stays unambiguous wherever it is read.
128+
$infoExtensions = ['generated' => $generatedAt->format(DateTimeInterface::ATOM)];
129+
}
130+
131+
$openApi->info = new OA\Info(
132+
version: $documentConfig['info']['version'] ?? null,
133+
title: $documentConfig['info']['title'] ?? null,
134+
x: $infoExtensions,
135+
);
136+
137+
$serverDescription = $documentConfig['server_description'] ?? null;
138+
if ($serverDescription !== null && ! is_string($serverDescription)) {
139+
fwrite(STDERR, 'The `openapi.server_description` config key must be a string.' . PHP_EOL);
140+
exit(1);
141+
}
142+
143+
// The project's own URL first, then `openapi.servers` — each URL once.
144+
try {
145+
$servers = OpenApiServers::fromConfig($baseUrl, $serverDescription, $documentConfig['servers'] ?? []);
146+
} catch (InvalidArgumentException $exception) {
147+
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
148+
exit(1);
149+
}
150+
151+
$openApi->servers = array_map(
152+
static fn (array $server): OA\Server => new OA\Server(
153+
url: $server['url'],
154+
description: $server['description'] ?? Undefined::UNDEFINED,
155+
),
156+
$servers,
157+
);
158+
159+
if (isset($documentConfig['external_docs']['url'])) {
160+
$openApi->externalDocs = new OA\ExternalDocumentation(
161+
description: $documentConfig['external_docs']['description'] ?? Undefined::UNDEFINED,
162+
url: $documentConfig['external_docs']['url'],
163+
);
164+
}
165+
166+
$tags = [];
167+
foreach ($documentConfig['tags'] ?? [] as $name => $description) {
168+
// An excluded tag has no operations left to describe, so declaring it would advertise an empty
169+
// section of the API.
170+
if (in_array((string) $name, $excludedTags, true)) {
171+
continue;
172+
}
173+
174+
$tags[] = new OA\Tag(name: (string) $name, description: $description);
175+
}
176+
177+
if ($tags !== []) {
178+
$openApi->tags = $tags;
179+
}
180+
181+
$securitySchemes = [];
182+
foreach ($documentConfig['security_schemes'] ?? [] as $name => $scheme) {
183+
$securitySchemes[] = new OA\SecurityScheme(
184+
securityScheme: (string) $name,
185+
type: $scheme['type'] ?? null,
186+
name: $scheme['name'] ?? null,
187+
in: $scheme['in'] ?? null,
188+
bearerFormat: $scheme['bearer_format'] ?? null,
189+
scheme: $scheme['scheme'] ?? null,
190+
);
191+
}
192+
193+
if ($securitySchemes !== []) {
194+
$openApi->components->securitySchemes = $securitySchemes;
195+
}
196+
197+
$result->saveAs($outputFile);
198+
199+
// The scan validates before the root is injected below, so a missing OA\Info is expected here.
200+
$expectedWarning = 'Required @OA\\Info() not found';
201+
202+
foreach ($result->warnings() as $warning) {
203+
if (str_contains($warning, $expectedWarning)) {
204+
continue;
205+
}
206+
207+
fwrite(STDERR, 'warning: ' . $warning . PHP_EOL);
208+
}
209+
210+
$errors = $result->errors();
211+
foreach ($errors as $error) {
212+
fwrite(STDERR, 'error: ' . $error . PHP_EOL);
213+
}
214+
215+
echo sprintf('Wrote %s (%d paths).' . PHP_EOL, $outputFile, count((array) $openApi->paths));
216+
217+
exit($errors === [] ? 0 : 1);

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"development-disable": "laminas-development-mode disable",
130130
"development-enable": "laminas-development-mode enable",
131131
"development-status": "laminas-development-mode status",
132+
"openapi": "php bin/generate-openapi.php",
132133
"post-update-cmd": [
133134
"php ./bin/generate-oauth2-keys.php",
134135
"php ./bin/composer-post-install-script.php"

‎config/autoload/openapi.global.php‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
/**
4+
* OpenAPI document root, consumed by bin/generate-openapi.php.
5+
*
6+
* These values cannot live in the OA attributes on Api\App\OpenAPI: attribute arguments must be
7+
* constant expressions, so they cannot read the base URL out of the local autoload config. The
8+
* server URL is taken from the `application.url` key defined there, and its label from the
9+
* optional `openapi.server_description` key — both are per-environment, so both belong in the
10+
* local config. Everything below is environment-independent and belongs in this file.
11+
*/
12+
13+
declare(strict_types=1);
14+
15+
return [
16+
'openapi' => [
17+
/**
18+
* Where bin/generate-openapi.php writes the document.
19+
*
20+
* `public/` is what makes the document reachable over HTTP; move it outside if the spec
21+
* should not be served, e.g. `'output_file' => 'data/openapi.yaml'`.
22+
*/
23+
'output_file' => 'public/openapi.yaml',
24+
/**
25+
* Tags whose endpoints are left out of the generated document.
26+
*
27+
* Use this to stop publishing a module's endpoints: name the tag its operations carry and both
28+
* the paths and the schemas only they referenced will disappear from the generated document.
29+
* The endpoints keep working — this hides them from the document, it is not access control.
30+
*
31+
* Match the tag exactly as the operations declare it, including spaces; the name is quoted
32+
* before it reaches the filter, so no escaping is needed here. Names listed below are also
33+
* dropped from the `tags` block, since an excluded tag has no operations left to describe.
34+
*
35+
* Example — keep the simulator and the card on-ramp out of the public document:
36+
*
37+
* 'exclude_tags' => [
38+
* 'Simulator',
39+
* 'Funding',
40+
* ],
41+
*
42+
* Regenerate with `composer openapi` afterward; the run reports the remaining path count.
43+
*/
44+
'exclude_tags' => [],
45+
/**
46+
* Servers published after the project's own URL.
47+
*
48+
* The first server in the generated document is always `application.url`, labelled with
49+
* `openapi.server_description`. The entries below follow it in this order, each with a `url` and an
50+
* optional `description`. A URL already in the list — the project's own included — is written once,
51+
* and the first occurrence keeps its description.
52+
*
53+
* Example — also publish a sandbox:
54+
*
55+
* 'servers' => [
56+
* ['url' => 'https://sandbox.example.com', 'description' => 'Sandbox'],
57+
* ],
58+
*
59+
* An entry without a non-empty `url` fails `composer openapi`.
60+
*/
61+
'servers' => [],
62+
/**
63+
* Timezone for the `info.x-generated` build timestamp, as an IANA identifier.
64+
*
65+
* OpenAPI has no field for when a document was built, so it is written as a specification
66+
* extension on the info object — the part of the document that describes the document rather
67+
* than the API. The value is ISO-8601 with the offset included, e.g.
68+
* `2026-09-08T10:39:29-04:00`, so it stays unambiguous across daylight saving.
69+
*
70+
* Set it to null to leave the field out. That also makes the output byte-identical between
71+
* runs, which is worth having if the document is committed — a timestamp changes on every
72+
* generation and shows up as a diff even when no endpoint did.
73+
*/
74+
'generated_timezone' => 'UTC',
75+
'openapi_version' => '3.1.0',
76+
'info' => [
77+
'title' => 'Dotkernel API',
78+
'version' => '1.0',
79+
],
80+
'external_docs' => [
81+
'description' => 'Dotkernel API documentation',
82+
'url' => 'https://docs.dotkernel.org/api-documentation/',
83+
],
84+
'tags' => [
85+
'AccessToken' => 'OAuth2 token issue and refresh.',
86+
'ActivateUser' => 'Administrative activation and deactivation of users.',
87+
'Admin' => 'Administrator records and the administrator\'s own account.',
88+
'AdminRole' => 'Administrator role catalogue.',
89+
'ErrorReport' => 'Error reporting for third-party clients.',
90+
'Home' => 'Application root.',
91+
'RecoverIdentity' => 'Recovery of a forgotten sign-in identity.',
92+
'ResetPassword' => 'Password reset request and completion.',
93+
'User' => 'User records and the caller\'s own account.',
94+
'UserAvatar' => 'User and account avatars.',
95+
'UserRole' => 'User role catalogue.',
96+
],
97+
'server_description' => 'Local development server',
98+
'security_schemes' => [
99+
'AuthToken' => [
100+
'type' => 'http',
101+
'bearer_format' => 'JWT',
102+
'scheme' => 'bearer',
103+
],
104+
'ErrorReportingToken' => [
105+
'type' => 'apiKey',
106+
'name' => 'Error-Reporting-Token',
107+
'in' => 'header',
108+
],
109+
],
110+
],
111+
];

‎src/Admin/src/OpenAPI.php‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
use Core\Admin\Entity\AdminRole;
2020
use Core\Admin\Enum\AdminRoleEnum;
2121
use Core\Admin\Enum\AdminStatusEnum;
22-
use DateTimeImmutable;
2322
use Fig\Http\Message\StatusCodeInterface;
2423
use OpenApi\Attributes as OA;
2524

@@ -460,8 +459,16 @@
460459
type: 'object',
461460
),
462461
),
463-
new OA\Property(property: 'created', type: 'object', example: new DateTimeImmutable()),
464-
new OA\Property(property: 'updated', type: 'object', example: new DateTimeImmutable()),
462+
new OA\Property(
463+
property: 'created',
464+
ref: '#/components/schemas/DateTimeObject',
465+
nullable: true,
466+
),
467+
new OA\Property(
468+
property: 'updated',
469+
ref: '#/components/schemas/DateTimeObject',
470+
nullable: true,
471+
),
465472
new OA\Property(
466473
property: '_links',
467474
properties: [

0 commit comments

Comments
 (0)