Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ composer.lock

.phpunit.result.cache
.phpcs-cache

public/openapi.yaml
217 changes: 217 additions & 0 deletions bin/generate-openapi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?php

/**
* Generates the OpenAPI document from the OA attributes under src/, to the path named by
* `openapi.output_file`.
*
* The document root — info, servers, external docs, security schemes — is not declared with
* attributes. PHP attribute arguments must be constant expressions, so they cannot read the API
* base URL out of the local autoload config; the root is assembled here from the `openapi` and
* `application` config keys instead and injected after the scan.
*
* `openapi.exclude_tags` keeps a module's endpoints out of the published document entirely.
*
* `openapi.servers` lists servers published after the project's own URL; see Api\App\Helper\OpenApiServers.
*/

declare(strict_types=1);

use Api\App\Helper\OpenApiServers;
use OpenApi\Attributes as OA;
use OpenApi\Builder;
use OpenApi\Generator;
use OpenApi\Undefined;
use OpenApi\Utils\SourceFinder;
use Psr\Container\ContainerInterface;

chdir(dirname(__DIR__));

require 'vendor/autoload.php';

const SOURCE_PATHS = ['src'];

/** @var ContainerInterface $container */
$container = require 'config/container.php';

/** @var array<string, mixed> $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<string, mixed> $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<non-empty-string> $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);
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions config/autoload/local.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
110 changes: 110 additions & 0 deletions config/autoload/openapi.global.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

/**
* OpenAPI document root, consumed by bin/generate-openapi.php.
*
* These values cannot live in the OA attributes on Api\App\OpenAPI: attribute arguments must be
* constant expressions, so they cannot read the base URL out of the local autoload config. The
* server URL is taken from the `application.url` key defined there, and its label from the
* optional `openapi.server_description` key — both are per-environment, so both belong in the
* local config. Everything below is environment-independent and belongs in this file.
*/

declare(strict_types=1);

return [
'openapi' => [
/**
* 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',
],
],
],
];
13 changes: 10 additions & 3 deletions src/Admin/src/OpenAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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: [
Expand Down
Loading
Loading