waffle-commons / openapi
OpenAPI 3.1 document generation for the Waffle framework, built from #[Route] attributes and typed DTOs.
Requires
- php: ^8.5
- waffle-commons/contracts: 0.1.0-beta7
- waffle-commons/utils: 0.1.0-beta7
Requires (Dev)
- carthage-software/mago: ^1.59
- cyclonedx/cyclonedx-php-composer: ^6.2
- igor-php/igor-php: ^0.10.1
- nyholm/psr7: ^1.8
- opis/json-schema: ^2.6
- php-mock/php-mock-phpunit: ^2.16
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.20
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-06 20:14:27 UTC
README
Waffle OpenApi Component
Release:
0.1.0-beta7ย |ยCHANGELOG.mdStatus: First released in0.1.0-beta7โ roadmap[API-01](RFC-016). Generation runs at build time (openapi:generate); nothing is added to the request hot path.
OpenAPI 3.1 document generation for the Waffle framework, built from #[Route] attributes and typed DTOs.
๐ฆ Installation
composer require waffle-commons/openapi
๐ Generating the document
Register the command in bin/waffle next to the console commands, with the booted router:
use Waffle\Commons\OpenApi\Command\GenerateCommand; $application->add(new GenerateCommand($router));
bin/waffle openapi:generate --output=public/openapi.json --title="Orders API" --api-version=1.4.0 bin/waffle openapi:generate --output=- # to the standard output
The document (OpenAPI 3.1) is written atomically โ a temporary file renamed over the target โ and validates against the official OpenAPI 3.1 schema (the component's tests check it). Nothing runs on a request: the generator reflects on the controllers at build time only.
๐งฉ What the document describes
The routes come from RouterInterface::getRoutes(), the metadata route:list and route:compile read.
For each route, the generator reflects on its controller method:
| Source | Becomes |
|---|---|
{name} / {name:constraint} in the path |
a required path parameter typed from the controller parameter of the same name (int โ integer, float โ number, bool โ boolean, else string); a string parameter's constraint becomes its pattern |
the first parameter typed with a #[Dto] class |
the JSON request body; required unless the parameter is nullable or defaulted |
return type #[Dto] |
200 with the JSON schema of the DTO |
return type array / string |
200 JSON / 200 HTML (no schema) |
return type void |
204 |
| a nullable return type | adds 204 |
| any other or no return type | a bare 200 |
Each HTTP method of a route is an operation; its operationId is the route name, suffixed with the
method when the route serves several, and numbered when two routes share a name. A route that accepts
any method is documented for GET, POST, PUT, PATCH and DELETE; a method OpenAPI 3.1 cannot describe is
left out, and a HEAD response carries no body.
Schemas come from Waffle\Commons\Utils\Dto\Shape\DtoShapeReader, the reader the serializer's
compiled normalizers use, so the document describes exactly what the serializer accepts and emits:
- a request body is the DTO's constructor parameters โ required when they have no default and are not nullable, with their defaults;
- a response body is its readable properties, all present (
nullincluded); a computed one (agethook) isreadOnly; - backed enums are
enumschemas of their backing values;#[ListOf]arrays arearrayschemas; nullable fields addnull.
DTOs and enums are shared under components/schemas, named after their short class name (the full
name, dotted, when two classes share it). A DTO whose request and response shapes differ, and that the
document uses on both sides, gets two components: Name-Input and Name-Output.
๐ท Saying what a signature cannot: #[OA\*]
The contracts' Waffle\Commons\Contracts\OpenApi\Attribute namespace adds descriptions, tags and
responses. Without any of them the document is still valid, only terse.
use Waffle\Commons\Contracts\OpenApi\Attribute as OA; #[OA\Operation(tags: ['users'])] // on the class: defaults for every route final class UserController { #[Route('/users/{id}')] #[OA\Operation(summary: 'Fetch a user', description: 'CommonMark allowed.')] #[OA\Response(200, 'The user.')] // a status already documented: new description #[OA\Response(404, 'No user has this id.')] // any other status: added public function show(int $id): UserView { /* โฆ */ } } #[Dto] final readonly class UserView { public function __construct( #[OA\Property(description: 'Public identifier.', example: 42)] public int $id, ) {} }
| Attribute | Where | What it does |
|---|---|---|
Operation |
method, or class for defaults | summary, description, tags, deprecated. The method's summary and description win, its tags replace the class's when it has any, and either level can mark an operation deprecated. |
Response (repeatable) |
method | status, description and an optional #[Dto] body type. A status the signature already implies takes the new description (and body, given a type); any other status is added. |
Property |
constructor parameter or property | description and example (written as JSON Schema's examples). The request side reads the parameter, the response side the property; a promoted parameter carries it on both. |
A Response whose status is not between 100 and 599, or whose type is not a #[Dto] class, fails the
generation with an Exception\InvalidAttributeException that names the method.
๐ The development documentation route
Middleware\ApiDocsMiddleware serves, during development, GET /_docs (a Swagger UI page) and
GET /_docs/openapi.json (the document generated from the routes at that moment). Mount it in debug
mode only, before the routing middleware: production refuses debug mode, so there both paths fall
through to the router, a 404 unless one of the application's routes matches them. The skeleton does
exactly that.
use Waffle\Commons\OpenApi\Middleware\ApiDocsMiddleware; if ($debug) { $stack->add(new ApiDocsMiddleware($router, $responseFactory, $streamFactory, 'Orders API', '1.4.0')); }
The page loads Swagger UI 5.33.3 from jsDelivr, both files pinned by their Subresource Integrity hashes,
and sends its own Content-Security-Policy: scripts from jsDelivr plus the inline start-up script (by its
hash), styles from jsDelivr plus the inline styles Swagger UI writes, images from the page and data:,
requests to the page's own origin only. Swagger UI's online validator is off.
๐งญ Architectural boundary (mago guard)
Waffle\Commons\OpenApi depends only on waffle-commons/contracts, waffle-commons/utils (the
DTO-shape reader) and the PSR interfaces โ never on a sibling component's concrete classes. The routes
come through RouterInterface, and the command implements Console\CommandInterface ([CARRY-04]).
๐งต Worker safety
Generation is a build-time step: the generator, the schema catalog and the command keep no state between
calls (final readonly classes), and composer igor reports 0 KO.
๐งช Testing
composer mago # formatter, linter, analyzer, guard โ clean means zero output composer tests # PHPUnit 12.5, line coverage โฅ 95 % composer igor # worker-safety audit
๐ Documentation
Full documentation tree: waffle-commons/documentation
๐ License
MIT โ see LICENSE.md.