OpenAPI & documentation¶
The bundle generates a faithful, rich OpenAPI 3.1 description of your JSON:API surface directly from the metadata you already declare — your resources, fields, constraints, relationships, filters, sorts, paginators, custom actions and security expressions. There is no second source of truth to keep in sync: the document is projected from the live registry, served live, exportable for CI, and — crucially — provably describes the responses you actually serve (see conformance).
Because the projection reads first-class JSON:API metadata (not a generic
PHP-attribute scrape), it can express the JSON:API document shape precisely: typed
attribute schemas from your constraints, linkage and compound-document envelopes,
enumerated filter[…] / sort / include / fields[…] / page[…] parameters, and the
standard error-status set per operation.
Per-operation response declarations¶
By default each operation advertises one success response — POST → 201,
PATCH → 200, DELETE → 204, GET → 200. A resource overrides the set per
operation with the
create/update/delete/fetchOne/fetchCollection params
on #[AsJsonApiResource], and a custom action
with its responds set. This is how the asynchronous-write lifecycle
(async writes) becomes part of the contract:
new Accepted('export-jobs')→ a202 Acceptedwhose body is theexport-jobsdocument, withContent-Location(the poll URL) andRetry-Afterheaders.new SeeOther()onfetchOne→ a303 See Otherwith aLocationheader (the completed job redirecting to the produced resource), no body.new NoContent()→ a204;new MetaResult()→ a200meta-only document.
So a codegen client sees, from the document alone, that POST /catalog-exports
returns 202 pointing at an export-jobs resource, and that GET /export-jobs/{id}
may 303 to the finished export. These responses ride core's shared projection, so
the Laravel package's document for the same domain is byte-identical.
Enabling generation¶
OpenAPI generation is on by default. The HTTP routes that serve it are gated for safety (see exposure), but the document is always buildable via the CLI.
# config/packages/json_api.yaml
json_api:
base_uri: 'https://api.example.com'
version: '1.1'
openapi:
enabled: true # generation available at all (CLI always works)
info:
title: 'My API'
version: '1.0.0'
description: 'A JSON:API surface.'
Import the docs routes once, alongside your resource routes:
# config/routes/json_api.yaml
json_api:
resource: .
type: jsonapi
json_api_openapi:
resource: .
type: jsonapi_openapi # mounts /docs.json (+ /{server}/docs.json) and /docs
The routes¶
| Route | Path | Serves |
|---|---|---|
| Document (default server) | GET /docs.json |
the OpenAPI 3.1 JSON document |
| Document (named server) | GET /{server}/docs.json |
that server's document (per-server mode) |
| Viewer | GET /docs |
a Swagger UI or ReDoc page rendering the document |
| JSON Schemas (default server) | GET /schemas.json |
the per-type JSON Schemas, keyed by type |
| JSON Schemas (named server) | GET /{server}/schemas.json |
that server's schemas (per-server mode) |
All paths are configurable (json_api.openapi.json.path, …ui.path,
…json_schema.path). The document and the schemas are served as application/json;
they are not JSON:API routes, so they carry no application/vnd.api+json
negotiation.
Try it live. The
examples/music-catalog-symfonyapp serves these routes. Rundocker compose upin that directory, then open http://localhost:8080/docs for the Swagger UI (raw document at/docs.json; theadminserver's document at/admin/docs.json).
Exposure: the docs routes¶
The HTTP routes are auto-exposed when kernel.debug is true (your dev environment)
and off in production unless you opt in. The CLI export is always available.
json_api:
openapi:
expose_in_prod: true # serve /docs.json + /docs + /schemas.json outside kernel.debug too
The viewer route additionally honours ui.enabled, and the JSON Schema routes honour
json_schema.enabled, so you can serve the raw document in prod while keeping the human
viewer (or the schemas) dev-only — or any other combination.
Profile-gated parameters: x-profile¶
Some query parameters are recognised by the runtime only when a JSON:API
profile is negotiated on the request (its
URI supplied via the profile media-type parameter of Accept /
Content-Type). withCount is the built-in case — the count tokens are honoured
only under the Countable profile.
The served document advertises this constraint honestly: every such parameter
carries an x-profile vendor extension whose value is the profile URI that must
be negotiated for the parameter to take effect. A client (or a codegen tool) that
does not negotiate that profile should treat the parameter as inert. For example, the
withCount parameter on a countable collection is emitted as:
{
"name": "withCount",
"in": "query",
"schema": { "type": "array", "items": { "type": "string", "enum": ["_self_", "tracks"] } },
"style": "form",
"explode": false,
"x-profile": "https://haddowg.github.io/json-api/profiles/countable/"
}
The extension is produced by the core projection (so it is present on every served build path — the warmer, the controllers, and the CLI export alike); the bundle simply serves the projected document verbatim.
The viewer¶
A single config-driven route renders the document with Swagger UI or ReDoc (one, not both), as plain CDN-linked HTML — no Twig dependency, no asset build step.
json_api:
openapi:
ui:
enabled: true
renderer: swagger # swagger | redoc
path: /docs
cdn: ~ # override the pinned CDN origin (self-host / air-gap)
The page loads Swagger UI / ReDoc from a pinned public CDN (jsDelivr) and points it at
the document route, generated via the router (so the spec URL honours any routing prefix
you mount the imported routes under — ->prefix('/api') — as well as the front-controller
script base; the viewer and the document it points at always share the same mount). For an
air-gapped deployment, mirror the assets
and set ui.cdn to your origin; document a matching script-src / style-src CSP
allowance for whichever origin you use.
To replace the viewer wholesale, register your own controller on ui.path — because your
app imports the docs route loader, your own GET /docs route wins by registration order.
Production: the cache warmer¶
The document is never built per request. A CacheWarmer pre-builds each server's
document, the per-type JSON Schemas, and the aggregate schema document at cache:warmup
— i.e. on every deploy — into %kernel.cache_dir%; the controllers then serve the
pre-built artifacts with an O(file read). In dev (kernel.debug) the controllers
lazy-build and cache on demand, since resources change between edits.
The warmer is optional and non-fatal: a documentation-generation failure never breaks a deploy (it logs and the controller's lazy build is the safety net). To also emit a fully static file a web server / CDN can serve with zero PHP:
json_api:
openapi:
public_path: '%kernel.project_dir%/public/openapi' # writes <server>.json (+ .yaml + <server>.schemas.json) at cache:warmup
Serving the JSON Schemas¶
Each type's resource object is also published as a standalone JSON Schema 2020-12
document — the same projection the OpenAPI components use, wrapped with the $schema
dialect keyword and an addressable $id so it validates on its own. They are served
over HTTP as a single aggregate keyed by JSON:API type at GET /schemas.json
(and GET /{server}/schemas.json per named server) — one fetch a client generator
consumes to drive an opt-in request/response validation seam:
{
"albums": { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:jsonapi:schema:albums", "type": "object", "properties": { "type": { "const": "albums" }, ... } },
"tracks": { "$schema": "...", "$id": "urn:jsonapi:schema:tracks", ... }
}
The routes ride the same expose gate as the document, with their own toggle and path:
json_api:
openapi:
json_schema:
enabled: true # register GET /schemas.json (+ /{server}/schemas.json); default true
path: /schemas.json # the default server's aggregate path
In combined multi-server mode GET /schemas.json serves one aggregate spanning
every server. The same schemas are available without HTTP exposure from the
CLI (json-api:json-schema:export).
Exporting from the CLI¶
Two commands publish the document and the standalone schemas with no HTTP exposure — for
CI spec-diffing, publishing and codegen. They establish the bundle's json-api: command
namespace.
# The OpenAPI document (JSON to stdout, or a file; YAML needs symfony/yaml):
php bin/console json-api:openapi:export
php bin/console json-api:openapi:export --server=admin --format=yaml --output=openapi.yaml
# Standalone per-type JSON Schema 2020-12 documents:
php bin/console json-api:json-schema:export --type=articles # one type → stdout
php bin/console json-api:json-schema:export --output=build/schemas/ # every type → directory
The standalone JSON Schemas are projected by the same core projector the OpenAPI
document uses, so a standalone <type>.json and the in-document <Type>Resource
component agree.
Customisation¶
Inline authoring (descriptions & examples)¶
Add ->describedAs() and ->example() to any field, relation or filter builder; the
projection surfaces them on the corresponding schema / parameter.
use haddowg\JsonApi\Resource\Field\Str;
Str::make('title')
->required()->minLength(3)->maxLength(120)
->describedAs('The article headline, shown in listings.')
->example('JSON:API in PHP');
The constraints you already declare (minLength, pattern, in, min/max, …) map
1:1 to JSON Schema 2020-12 keywords — that fidelity is the whole reason the target is
3.1. The few constraints with no lossless keyword (When with a dynamic condition,
CompareField, the After/Before/Between date bounds) degrade to a human-readable
note in the schema description rather than emitting a wrong schema.
Describing types, operations & relationships¶
Every documentable element has a generated default description, and every one of them is overridable declaratively (never by reaching into the document with a decorator). There are three override surfaces:
- a type's resource-object schema —
#[AsJsonApiResource(description: …)], or the resource'sgetDescription(): ?stringmethod hook; - a CRUD operation (collection / fetch-one / create / update / delete) —
#[AsJsonApiResource(operationDescriptions: […])]keyed by theOperationcase name, or the resource'sdescribeOperation(OperationType $op): ?stringmethod hook; - a relationship's related + relationship operations —
->describedAs(…)on the relation field (the one description applies to every endpoint of that relationship).
use haddowg\JsonApi\OpenApi\Metadata\OperationType;
use haddowg\JsonApi\Resource\AbstractResource;
use haddowg\JsonApi\Resource\Field\BelongsTo;
use haddowg\JsonApiBundle\Attribute\AsJsonApiResource;
use haddowg\JsonApiBundle\Operation\Operation;
#[AsJsonApiResource(
description: 'A sellable product in the catalog.',
operationDescriptions: [
Operation::FetchCollection->name => 'Browse the product catalog.',
],
)]
final class ProductResource extends AbstractResource
{
public function fields(): array
{
return [
// … the relation description flows onto GET /products/{id}/category
// and GET /products/{id}/relationships/category.
BelongsTo::make('category', 'categories')
->describedAs('The catalog category this product belongs to.'),
];
}
// Equivalent to the attribute fields above; useful when the text is computed.
public function getDescription(): ?string
{
return 'A sellable product in the catalog.';
}
public function describeOperation(OperationType $op): ?string
{
return $op === OperationType::Delete ? 'Permanently retires a product.' : null;
}
}
Precedence is method hook → attribute → generated default: when a resource declares
both, the method hook wins (it is the more specific, runtime surface). Returning null
(the method default) or omitting the attribute key leaves the generated default in place.
An unknown operationDescriptions key fails the build with a clear error.
Tagging (grouping operations)¶
OAS tags drive how Swagger UI / ReDoc group operations. Each resource's operations are
grouped under one tag; the default is the humanized, title-cased type (blog-post →
Blog Posts). Override per resource and per action:
#[AsJsonApiResource(tags: ['Catalog'])]
final class ProductResource extends AbstractResource { /* … */ }
#[AsJsonApiAction(tags: ['Catalog'])]
final class RecalculatePrices implements ActionHandlerInterface { /* … */ }
An action with no tags inherits the resource tag of the type it mounts on. The
top-level tag definitions (description, externalDocs, order) are config-authoritative;
any tag referenced but not defined is auto-synthesized (name only):
json_api:
openapi:
tags:
- name: Catalog
description: 'Products and catalog operations'
externalDocs: { url: 'https://docs.example.com/catalog' }
Enum descriptions¶
An In-constrained attribute sourced from a backed enum becomes a reusable named
component (#/components/schemas/<Enum>), $ref'd everywhere it is used. Declare
per-value descriptions with the core enum trio:
use haddowg\JsonApi\Resource\Enum\DescribedEnum;
use haddowg\JsonApi\Resource\Enum\DescribesEnumCases;
use haddowg\JsonApi\Resource\Enum\EnumCaseDescription;
enum CatalogStatus: string implements DescribedEnum
{
use DescribesEnumCases;
#[EnumCaseDescription('Not yet visible in the catalog')] case Draft = 'draft';
#[EnumCaseDescription('Live and listed')] case Published = 'published';
#[EnumCaseDescription('Withdrawn from sale')] case Archived = 'archived';
}
The descriptions are emitted both as a markdown value → description table in the
schema description (the only form the free CDN renderers show) and as
x-enum-varnames / x-enum-descriptions vendor extensions (codegen-portable). Switch
with enum_value_descriptions: both | extensions | description (default both).
Security schemes¶
Declare named security schemes; operations carrying a security* expression get the
configured requirement. The authz expression itself is never parsed for scheme semantics
— you declare the scheme, the projection attaches it.
json_api:
openapi:
security:
schemes:
bearer: { type: bearer, bearerFormat: JWT } # bearer = shorthand for http + bearer
apiKey: { type: apiKey, apiKeyName: X-Api-Key, in: header }
oidc: { type: openIdConnect, openIdConnectUrl: 'https://issuer.example.com/.well-known/openid-configuration' }
oauth: # full oauth2 flows graph
type: oauth2
flows:
authorizationCode:
authorizationUrl: 'https://auth.example.com/authorize'
tokenUrl: 'https://auth.example.com/token'
refreshUrl: 'https://auth.example.com/refresh' # optional
scopes:
'catalog:read': 'Read the catalog'
'catalog:write': 'Manage the catalog'
# also: implicit | password | clientCredentials
default_requirement: [bearer]
Every scheme type the OpenAPI 3.1 spec defines is supported — http/bearer, apiKey,
openIdConnect, and oauth2 (with its implicit / password / clientCredentials /
authorizationCode flows). A scheme that cannot be built (e.g. an oauth2 with no flow,
or an openIdConnect with no URL) is dropped rather than emitting an invalid document, and
a default_requirement naming a scheme that did not resolve is dropped with it (the
document never carries a dangling security reference).
#[AsJsonApiResource(securityRead: 'is_granted("ROLE_USER")')]
final class PlaylistResource extends AbstractResource { /* … */ }
Wholesale customisation (the decorator)¶
For anything the projection can't express declaratively — a server variable, an extra
scheme, per-individual-operation tags, vendor extensions, hand-written examples, or
rewriting any part of the document — implement OpenApiFactoryInterface. It is
autoconfigured and applied after the projection on every build path (warmer,
controller, CLI), so your decorator gets the last word.
use haddowg\JsonApi\OpenApi\OpenApi;
use haddowg\JsonApiBundle\OpenApi\OpenApiFactoryInterface;
final class StampInternalServer implements OpenApiFactoryInterface
{
public function decorate(OpenApi $document, string $server): OpenApi
{
return $document->withServers([
...$document->servers,
new \haddowg\JsonApi\OpenApi\Server('https://internal.example.com', 'Internal'),
]);
}
}
Register multiple by priority (#[AsTaggedItem] or a priority tag attribute); lower
priority runs first, the highest-priority decorator gets the final mutation.
Multi-server¶
With several JSON:API servers, the default is one
document per server (/docs.json for the default server, /{server}/docs.json for
each named server). Switch to a single union document with multi_server: combined:
json_api:
openapi:
multi_server: combined # one document spanning every server, at the json path only
Conformance: proving the document describes reality¶
The headline guarantee is round-trip: the generated document is proven to describe the
responses you actually serve. The bundle ships a test trait, SchemaConformanceTrait,
that validates a real response body against the generated component schema for its
type — so a drift between your document and your API fails a test.
use haddowg\JsonApiBundle\Testing\SchemaConformanceTrait;
use haddowg\JsonApiBundle\Testing\SchemaDocumentKind;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class OpenApiConformanceTest extends KernelTestCase
{
use SchemaConformanceTrait;
public function test_single_resource_matches_its_schema(): void
{
$response = /* your functional GET of /articles/1 → an HttpFoundation Response */;
$this->assertResponseMatchesGeneratedSchema($response, 'articles', SchemaDocumentKind::Single);
}
public function test_collection_and_relationship_match(): void
{
$this->assertResponseMatchesGeneratedSchema($collection, 'articles', SchemaDocumentKind::Collection);
$this->assertResponseMatchesGeneratedSchema($related, 'articles', SchemaDocumentKind::Related, 'author');
$this->assertResponseMatchesGeneratedSchema($linkage, 'articles', SchemaDocumentKind::Relationship, 'author');
}
}
SchemaDocumentKind selects which envelope component validates the response — Single
(GET /{type}/{id}), Collection (GET /{type}, incl. a compound ?include document),
Relationship (…/relationships/{rel} linkage) or Related (…/{rel} for a to-one).
The trait builds the document once per server through the same DocumentFactory the
warmer / controller / CLI use, and validates over opis/json-schema — which implements
the 2020-12 dialect natively, so the helper is fully offline (no meta-schema to vendor).
assertBodyMatchesGeneratedComponent($body, '<Component>') is the escape hatch for a
component the four kinds don't name (a custom enum component, a per-relation collection).
This is a real, biting guarantee: when a response diverges from its schema, the assertion names exactly which member diverged. The bundle's own suite runs these assertions across both the in-memory and Doctrine providers, so the generated document is proven against the wire output of both data layers.
Beyond the response round-trip, every emitted document is also validated against the official OAS 3.1 meta-schema in the bundle's tests, so the document is well-formed OpenAPI as well as accurate.