Skip to content

v1.57.0 - Sargas: types-first DTO I/O + a single code-first OpenAPI generator

Choose a tag to compare

@MichaelSowah MichaelSowah released this 14 Jun 22:14
3495be9

Theme: Types-first I/O & a single code-first OpenAPI generator. A typed-DTO request/response convention (RequestData/ResponseData, CollectionResponse/PaginatedResponse, #[Rule]/#[ResponseStatus]/HasResponseMessage) where types drive both runtime enveloping and the generated spec, plus auto-422, JsonResource/ResourceCollection auto-normalization, and a scaffold:dto command. The OpenAPI generator is consolidated to a single code-first reflect engine: the legacy docblock-parsing comments generator is removed, structure is derived from the live route table + types, and a minimal typed attribute surface (#[ApiOperation]/#[QueryParam]/#[ApiRequestBody]/#[ApiResponse]) documents only what types can't express. Minor release (new features + the comment-generator removal + default changes), per the pre-public cadence. Has ### Upgrade Notes.

Added

  • OpenAPI reflect generator: hand-authored override attributes (Phase 3 Stage 1). Four method-level attributes in Glueful\Routing\Attributes\ let you fill the gaps the code-first reflect generator cannot infer from types alone — structure (paths, params, security, request/response schemas) stays inferred; these cover what types cannot express. #[ApiOperation] overrides the derived summary, description, tags, operationId, and adds deprecated; every field is optional and omitting a field keeps the derived value. #[QueryParam] (repeatable) documents arbitrary query parameters (?days=30, ?status=active) not expressible as PHP types; parameters are merged path → field-selection → #[QueryParam] and deduped by (name, in) with #[QueryParam] winning over a generated param of the same name (e.g. overriding the auto-generated fields param). #[ApiRequestBody] documents a request body the generator can't infer from a hydrating RequestData param (a handler that must stay manual — polymorphic login, resource store/update — or a non-JSON body) via a two-parameter, type-first API: schema: is a DTO class reflected to a raw object schema for a (usually application/json) doc-only body, while inlineSchema: is a raw OpenAPI array for non-JSON bodies only (multipart file uploads, application/octet-stream, etc.). Exactly one of the two must be set (XOR), and inlineSchema with contentType: 'application/json' is rejected with InvalidArgumentException at load time (it would recreate the inline-JSON mini-language). When present the attribute still wins over RequestData/#[Validate] inference; when absent that inference is unaffected. #[ApiResponse] gains a new body: parameter ('binary'|'text'|'object') for non-JSON response bodies a DTO cannot model (file downloads, HTML pages, opaque blobs); body: is honored only when schema is null — when both are set the DTO class schema wins; an invalid value throws InvalidArgumentException at load time. All four attributes are read by RouteReflectionDocGenerator and are fully guarded (generation never throws). See docs/OPENAPI_REFLECT.md for the complete override surface and DTO-first usage guide.
  • Glueful\Http\Contracts\HasResponseMessage — a returned ResponseData/CollectionResponse/PaginatedResponse may now supply its own envelope message via responseMessage() (defaults unchanged — 'Success'/'Created successfully'/'Data retrieved successfully' — when not implemented).
  • Typed response DTOs: a controller method returning a ResponseData is automatically enveloped into the standard {success, message, data} response. A new empty marker interface Glueful\Http\Contracts\ResponseData is the only contract required — when Router::normalizeResponse() receives a ResponseData return value it delegates serialization to the new pure Glueful\Serialization\ResponseDataSerializer::toArray() and wraps the result in Response::success($data) (200), Response::created($data) (201), or an equivalent envelope for other 2xx codes. A new method attribute Glueful\Routing\Attributes\ResponseStatus sets the success status (#[ResponseStatus(201)] → 201 Created); the attribute constructor rejects non-2xx codes with InvalidArgumentException at load time (fail-loud, not silently discarded). ResponseDataSerializer reflects public typed properties, skips uninitialized ones, and maps values: backed enum → ->value; pure enum → ->name; DateTimeInterface → ISO-8601 (format('c')); nested ResponseData → recursed; arrays → element-wise; a DTO that declares its own toArray() method bypasses reflection entirely (escape hatch). A cycle/depth guard (MAX_DEPTH = 5) terminates self-referential DTOs with null rather than looping. In documentation.generator='reflect' mode RouteReflectionDocGenerator infers the success-response OpenAPI schema from the handler's ResponseData return type (envelope-wrapped DTO schema at the #[ResponseStatus] status, defaulting to 200), providing symmetry between the runtime payload and the generated spec; an explicit #[ApiResponse] at the same status still overrides, and 4xx/error responses still come from #[ApiResponse] alone. v1 boundaries: ResponseData only — JsonResource/ResourceCollection (the rich transformation path) keep their own ->toResponse() envelope rather than the ResponseData envelope (and are auto-normalized through it — see the Resource auto-normalization entry below); public typed properties serialized (or a custom toArray()); success response only (error/4xx via #[ApiResponse]); collections and pagination are not first-class (wrap a list in a plain array property). See docs/RESPONSE_DTOS.md for the usage guide.
  • Typed request DTOs: a controller parameter implementing RequestData is auto-hydrated and validated from the JSON body. A new marker interface Glueful\Validation\Contracts\RequestData and attribute Glueful\Validation\Attributes\Rule (targets: parameter, property) let you express the request contract as a plain PHP class with constructor-promoted properties. When the router (Router::resolveMethodParameters()) encounters a method parameter whose type implements RequestData, it decodes the JSON body, validates each field against the parameter's #[Rule('required|email|…')] constraints via RequestDataHydratorRuleParserValidator, and passes the constructed, type-coerced DTO directly to the handler. Validation failure throws ValidationException (→ 422); malformed JSON throws ValidationException::forField('body', …) (→ 422). In documentation.generator='reflect' mode the OpenAPI request-body schema is inferred automatically from the DTO's constructor parameters and their #[Rule] constraints — no #[Validate] attribute needed. v1 boundaries: JSON body only (no form/multipart fallback); constructor-promoted properties only (non-promoted/non-public properties are not documented); use a RequestData parameter instead of #[Validate] for the body — in reflect docs a RequestData param supersedes #[Validate], but at runtime #[Validate] is enforced by the opt-in ValidationMiddleware before parameter resolution, so combining both on one route is unsupported; a non-scalar or un-ruled promoted param can surface a TypeError (500) rather than a 422. See docs/REQUEST_DTOS.md for the usage guide and v1 sharp edges.
  • OpenAPI: reflect generator documents response bodies from typed DTO classes via #[ApiResponse]. A new repeatable method attribute Glueful\Routing\Attributes\ApiResponse declares a route's responses by status (#[ApiResponse(200, UserData::class)], #[ApiResponse(404, description: 'Not found')]). In reflect mode each instance is reflected into an OpenAPI response object whose body schema is derived from the typed $schema DTO class by the new pure reflector Glueful\Support\Documentation\ClassSchemaReflector::toSchema(). The reflector maps a class's public, non-static typed properties to an OpenAPI object schema: scalars (string/int/float/bool), nullable ?T, DateTimeInterfacedate-time, backed enums → typed enum values (pure enums → case names), nested DTO classes (recursive, with a depth/cycle guard so self-referential DTOs terminate), and array properties whose item type is taken from a @var Foo[]/@var array<Foo> docblock when resolvable (otherwise items: {}); it is pure and never throws (a missing/uninstantiable class yields {type: object}). #[ApiResponse] options: collection: true wraps the schema as an array, and envelope: true (default) wraps it in Glueful's success envelope {success, message, data} (matching Response::success()); a schema-less attribute produces a description-only response. Attribute responses overlay the generator's default response set keyed by status — an explicit 200 replaces the minimal default while the auto 401/403/429 remain — so handlers with no #[ApiResponse] are unchanged. Handler-method reflection is shared with #[Validate] request-body inference and is fully guarded.
  • OpenAPI: DocGenerator now normalises inline path schemas for OpenAPI 3.1. getSwaggerJson() previously ran the 3.1 nullable transform (nullable: truetype: [T, 'null']) only over components.schemas; it now also walks each operation's parameters[].schema, requestBody.content.*.schema, and responses.*.content.*.schema, so inline schemas (e.g. a reflected DTO's nullable property) render in the correct 3.1 array form. No-op under 3.0.x; benefits both reflect and comments modes.
  • OpenAPI: reflect generator infers JSON request bodies from #[Validate]. In reflect mode, POST/PUT/PATCH operations now gain a requestBody derived from the #[Validate(...)] attribute on the route's handler method. A new pure mapper Glueful\Support\Documentation\ValidationRuleSchema::toObjectSchema() turns Laravel-style rule strings (field => 'required|email|min:8', or per-field arrays) into an OpenAPI object schema: required/type rules (string/integer/int/numeric/boolean/bool/array), string formats (email/uuid/urluri/date/datetime+date_formatdate-time), in:enum, and min:/max: as minLength/maxLength (strings) or minimum/maximum (numbers); unrecognised rules (confirmed, unique:, exists:, nullable, sometimes, …) are ignored and untyped fields default to string. The example payload reuses ExampleDeriver. Only body-bearing verbs are considered — #[Validate] on GET/DELETE/HEAD/OPTIONS (query validation) yields no body — and handler reflection is fully guarded, so closures, invokables, and unresolvable handlers simply produce no request body and never throw. Response bodies are documented separately via #[ApiResponse] (above).
  • OpenAPI: code-first reflect generator. New documentation.generator switch (env API_DOCS_GENERATOR) selecting 'comments' (default, unchanged docblock/JSON-fragment parsing) or 'reflect'. In reflect mode OpenApiGenerator derives the spec's paths directly from the live route table instead of parsing docblocks: per-route security is computed from the route's real middleware via the configured SecuritySchemeRegistry, path parameters (with where() constraints as pattern) and GraphQL-style fields/expand query params come from the route definition, required scopes are documented in prose, and rate-limited routes gain a 429 with the standard rate-limit headers. The Router is loaded the same way as route:debug (RouteManifest::load() + attribute scan); if it was built from the compiled route cache (which strips per-route metadata) the manifest is reset and rebuilt so reflection sees fresh Route objects. The same registry instance is shared between the DocGenerator and the reflect generator. Resource-route expansion (include_resource_routes) still applies. (Introduced this release as opt-in alongside the comment generator; later in this same release comments was removed and reflect became the sole, unconditional generator — see Removed.)
  • OpenAPI: opt-in pruning of unreferenced default schemas. New documentation.options.prune_unreferenced_schemas (env API_DOCS_PRUNE_UNREFERENCED_SCHEMAS, default false). When enabled, DocGenerator::getSwaggerJson() drops the built-in default component schemas (LoginRequest, User, Notification, …) that no path or webhook references, so SDK/codegen tools stop emitting dead types. Reachability is computed transitively from paths/webhooks, so referenced schemas and their dependencies are kept, and schemas contributed by route/extension fragments are always retained. Off by default for backward compatibility.
  • Typed collection and pagination response DTOs: CollectionResponse and PaginatedResponse (Glueful\Http\Responses\). Two new final value objects complete the typed-I/O response story for list endpoints. A handler returning CollectionResponse is rendered as Glueful's standard success envelope ({success, message, data: [...]}) at the handler's #[ResponseStatus] status (default 200, honored). A handler returning PaginatedResponse($items, $page, $perPage, $total) is rendered as the flat pagination envelope produced by Response::paginated() — keys success, message, data, current_page, per_page, total, total_pages, has_next_page, has_previous_pagealways at HTTP 200 (#[ResponseStatus] has no effect on a PaginatedResponse; use CollectionResponse for a non-200 collection status). Items in both wrappers are serialized element-wise via ResponseDataSerializer (ResponseData DTOs → arrays; plain arrays/scalars and non-ResponseData objects pass through). PaginatedResponse's constructor validates its metadata at construction time: page >= 1, perPage >= 1, total >= 0 (invalid values throw InvalidArgumentException immediately, preventing a division-by-zero in Response::paginated()).
  • OpenAPI: reflect-mode list-schema inference from @return CollectionResponse<Item> / @return PaginatedResponse<Item> docblocks. RouteReflectionDocGenerator now infers OpenAPI item schemas from the generic type parameter in the handler's return docblock. A fully-qualified class name or a same-namespace short name is resolved and reflected via ClassSchemaReflector::toSchema(); when the docblock is absent or the type cannot be resolved (including use-aliased names — aliases are not resolved), the items fall back to {type: object}. A CollectionResponse handler documents an envelope-wrapped {type: array, items: <Item schema>} at the #[ResponseStatus] status; a PaginatedResponse handler documents a flat paginated schema with all pagination keys in the required list, always at status 200. An explicit #[ApiResponse] at the same status overrides the inferred schema.
  • OpenAPI: auto-derived 422 response for handlers with a RequestData parameter. In documentation.generator='reflect' mode, any handler whose parameter list includes a RequestData-typed parameter now automatically documents a 422 Unprocessable Entity response matching the runtime validation-error body ({success: false, message: string, errors: {<field>: [string]}}). No annotation is required. An explicit #[ApiResponse(422, …)] on the same handler overrides the auto-derived entry.
  • scaffold:dto command. php glueful scaffold:dto <Name> generates a request DTO stub (implementing RequestData with a sample #[Rule] property) in app/DTOs/ (or src/DTOs/ in framework-dev mode). Pass --response (-r) to generate a response DTO stub (implementing ResponseData) instead. Pass --force (-f) to overwrite an existing file without prompting.
  • Returning a JsonResource, ResourceCollection, or PaginatedResourceResponse from a controller is now auto-normalized through its own ->toResponse() (the manual call is no longer required; #[ResponseStatus] is threaded as the status). Resource bodies are not reflectable, so document Resource-returning endpoints with #[ApiResponse].

Fixed

  • Reflect-mode docs no longer emit a vestigial description-only 200 response alongside a non-200 inferred success (e.g. a #[ResponseStatus(201)] handler now documents only 201).
  • ClassSchemaReflector schema fidelity. A @var array<…> property whose type sat on its own docblock line no longer leaks a bogus description: "*" into the generated schema (the same-line description regex was tightened). A DTO with no public typed properties now reflects to {type: object, properties: {}} (valid OpenAPI) instead of a malformed properties: [] — the honest representation of a generic open object.
  • OpenAPI generator: documentation config flags are now honored. The documentation.options.include_extensions and documentation.options.include_routes flags were never read in src/ (dead flags) and documentation.sources.include_framework_routes was applied only on the --force path. OpenApiGenerator::processExtensionAndRouteDocs() now reads all three (each defaulting to true, so existing specs are unchanged): include_extensions=false skips generating/merging extension fragments, include_routes=false skips app route fragments, and include_framework_routes=false now excludes the framework's own route files on both the force and non-force paths. An app can now produce a spec of only its own routes through config alone.
  • OpenAPI generator: stale-fragment leakage eliminated. The spec's paths were assembled by globbing the whole json-definitions/{routes,extensions} directories, so per-file fragments written by previous runs (including framework routes from an earlier --force) were re-merged on every subsequent run regardless of config. The generator now merges only the fragment files produced by the current run: forceGenerateExtensionDocs()/forceGenerateRouteDocs() return the files they generated, the non-force CommentsDocGenerator::generateAll() list is split into extension vs route fragments by directory, and the resulting lists (after flag gating) are merged via the new list-based merge methods.
  • generate:openapi --clean now removes nested fragments. cleanDefinitionDirectories() previously called @rmdir() on the routes//extensions/ subdirectories (which silently fails on non-empty dirs) and only unlink-ed *.json at depth 0, so routes/*.json and extensions/<ext>/*.json survived --clean and were re-merged on the next run. Cleaning is now recursive: every file at any depth is unlinked, then the now-empty subdirectories are removed deepest-first. The operation never throws if a path is already gone and reports an accurate file/directory count.

Changed

  • Reference example: AuthController::refreshToken() now uses typed request and response DTOs (Glueful\DTOs\RefreshTokenData for the request, Glueful\DTOs\RefreshedTokenData for the response) instead of manual body extraction and Response::success(...). The enveloped response body is byte-identical to before: data carries access_token, refresh_token, expires_in, token_type, user in the same declaration order; the envelope message ('Token refreshed successfully') is supplied via HasResponseMessage and stored in a private property so it is not serialized into data. A missing or blank refresh_token field is rejected by #[Rule('required|string')] as a 422 Validation Error — the SAME status as before (the old code threw ValidationException::forField('refresh_token', …), already a 422), so there is NO error-path status change. The login method was intentionally left manual (request is polymorphic — token/api_key shortcut vs credentials — and the response is polymorphic: full session vs 2FA challenge).
  • Reference example: ResourceController::destroy() now returns a typed response DTO (Glueful\Controllers\DTOs\ResourceDeletedData) instead of Response::success(...). The enveloped body is byte-identical to before: the data payload keeps its own affected/success/message keys (public promoted properties serialized into data), while the distinct outer envelope message ('Resource deleted successfully') is supplied via HasResponseMessage and stored in a private property so it is not leaked into data. ResourceController::index() was intentionally left unchanged — its Response::successWithMeta() body flattens the repository's pagination meta (current_page, per_page, total, last_page, has_more, from, to, execution_time_ms), a key set that differs from PaginatedResponse/Response::paginated() (which emits total_pages, has_next_page, has_previous_page instead of last_page, has_more, from, to, execution_time_ms); migrating would drop and add keys, breaking the response contract. Permission-denied / not-found paths still return a plain Response.
  • Reference example: UploadController's info/signedUrl/delete endpoints now return typed response DTOs (Glueful\Controllers\DTOs\BlobInfoData, SignedUrlData, BlobDeletedData) instead of Response::success(...). The enveloped bodies are byte-identical to before — BlobInfoData uses the serializer's toArray() escape hatch to pass the blob row through verbatim (preserving arbitrary column order and the omit-when-absent native_url key), SignedUrlData declares native_url as an uninitialized typed property (skipped by the serializer when absent, emitted last when present), and each DTO supplies its envelope message via HasResponseMessage. notFound/unauthorized/disabled paths still return a plain Response.
  • Reference example: HealthController::readiness() now returns a typed response DTO (Glueful\Controllers\DTOs\ReadinessData, ResponseData + HasResponseMessage) on its 200 success path — body byte-identical (the per-request timestamp passes through unchanged); the 503 "not ready" branch still returns a plain Response::error(...). HealthController::database() was intentionally NOT migrated: it wraps its success in privateCached() to attach Cache-Control headers, which the ResponseData return path cannot carry — so endpoints that set response-level headers (caching, custom headers) stay manual. This is a documented convention boundary: a typed ResponseData return controls body + status + message, not headers.
  • OpenAPI: DocGenerator gains explicit-file-list merge methods. Added DocGenerator::generateFromExtensionFiles(array $files) and generateFromRouteFiles(array $files), which merge a caller-supplied list of fragment files instead of globbing a directory. The existing generateFromExtensions(?string $path) / generateFromRoutes(?string $path) glob methods are unchanged and remain available for backward compatibility.

Removed

  • OpenAPI: the legacy comments (docblock-parsing) generator has been removed; reflect is now the only generator. The CommentsDocGenerator (PHPDoc/@route/@response docblock and JSON-fragment route parsing) is deleted, along with the documentation.generator config switch and the API_DOCS_GENERATOR env var — the code-first reflect generator (introduced earlier in this same release) is now unconditional — its prior opt-in default of 'comments' no longer applies. OpenApiGenerator derives the spec's paths solely from the live route table; its constructor no longer accepts a CommentsDocGenerator argument (signature is now (ApplicationContext $context, ?DocGenerator $docGenerator = null, ?FileFinder $fileFinder = null, bool $runFromConsole = false, ?ResourceRouteExpander $resourceExpander = null)), and the getCommentsGenerator() accessor and processExtensionAndRouteDocs() orchestration are gone. Preserved: resource-route expansion (include_resource_routes); route-origin filtering (documentation.sources.include_framework_routes / documentation.options.include_extensions, now read by the reflect generator); and merging of hand-authored custom OpenAPI JSON fragments placed at the TOP LEVEL of docs/json-definitions/ (via DocGenerator::generateFromDocJson, still run). The json-definitions/{routes,extensions}/ subdirectories — which held the comment generator's per-file output — are intentionally NOT merged (re-merging that stale machine output would resurrect routes the reflect generator now derives live); the list-merge helpers DocGenerator::generateFromExtensionFiles/generateFromRouteFiles remain available but are no longer invoked by the pipeline. The @* route docblocks in routes/*.php are now inert (no longer parsed) but harmless. Operators who set API_DOCS_GENERATOR should remove it; the value is ignored.

Upgrade Notes

  • The comment-based OpenAPI generator has been removed.
  • reflect is now the only OpenAPI generator.
  • documentation.generator and API_DOCS_GENERATOR are no longer supported.
  • Route @route, @summary, @requestBody, @response, and related docblock annotations are no longer read.
  • Use typed DTOs, #[ApiOperation], #[QueryParam], #[ApiRequestBody], and #[ApiResponse] for OpenAPI documentation.