|
| 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); |
0 commit comments