Search by

OpenAPI 3.1 document generation for the Waffle framework, built from #[Route] attributes and typed DTOs.

Package info

github.com/waffle-commons/openapi

pkg:composer/waffle-commons/openapi

Statistics

Installs: 15

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

0.1.0-beta7 2026-11-05 01:39 UTC

This package is auto-updated.

Last update: 2026-11-06 20:14:27 UTC


README

Discord PHP Version Require PHP CI codecov Latest Stable Version Latest Unstable Version Total Downloads Packagist License

Waffle OpenApi Component

Release: 0.1.0-beta7 ย |ย  CHANGELOG.md Status: First released in 0.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 (null included); a computed one (a get hook) is readOnly;
  • backed enums are enum schemas of their backing values; #[ListOf] arrays are array schemas; nullable fields add null.

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.