Customizing the generated documentation
Docuccino documents your API by reading your code, and most of the time that’s all you need. When a detail comes out wrong, thin, or in the wrong place, you have three ways to correct it. The trick is picking the right one — the closer a correction lives to the code, the more it survives change.
Which tool for which change
Section titled “Which tool for which change”Start from two questions: is the detail wrong or just missing, and do you own the code it comes from?
| The detail is… | You own the code? | Reach for |
|---|---|---|
| A fact about one operation that inference got wrong, or left thinner than you’d like | Yes | An attribute or docblock at the source |
| On a route you can’t edit — a vendor or third-party package route — or a batch of spec-side tweaks you’d rather keep out of the code | No | An OpenAPI Overlay |
| A rule that should hold across a whole document — naming, wrapping, a synthesized section | Either | Config, a representation policy, or a DocumentTransformer |
The rest of this guide is about the middle row — Overlays — with a note at the end on when to step up to a document-wide transformer.
OpenAPI Overlays in practice
Section titled “OpenAPI Overlays in practice”An OpenAPI Overlay 1.0 document is a small, standard YAML file that lists targeted edits to apply to a specification. Docuccino applies your overlays as part of every build, so the corrections survive regeneration without ever touching the generated document by hand.
Anatomy of an overlay
Section titled “Anatomy of an overlay”overlay: 1.0.0info: title: Invoicing API corrections version: 1.0.0actions: - target: "$.paths['/api/v1/invoices'].get.summary" update: List paginated invoices - target: "$.paths['/api/v1/invoices'].get.parameters[?(@.name=='internal_cursor')]" description: Internal pagination cursor, not part of the public contract. remove: trueEvery overlay has an overlay: 1.0.0 version line, an info block, and an ordered list of
actions. Each action carries a target — a JSONPath expression selecting the node to change — and
exactly one operation:
updatemerges an object into the target (deep, with the overlay winning field by field), or replaces a scalar or array outright.removedeletes the targeted node.
An action’s optional description is a note to your future self: Docuccino reads it and keeps it out
of the document, so it’s the right place to record why a correction exists.
Where overlays live
Section titled “Where overlays live”Point the document’s overlays setting at one or more globs, relative to your application root. Files
are applied in glob order, and the actions inside each file in the order written:
documents: default: overlays: - 'resources/docs/overlays/*.yaml'The golden rule: overlays edit what already exists
Section titled “The golden rule: overlays edit what already exists”Overlay 1.0 targets nodes in a document. A target that resolves to nothing is not an instruction to create it — the action simply has no effect (and Docuccino warns). So when you want to add something, merge onto the parent that exists rather than targeting the key that’s missing:
| You want to add | Don’t target | Do target |
|---|---|---|
A description on an operation |
….get.description |
….get, with update: {description: …} |
An example on a response body |
….content['application/json'].example |
….content['application/json'], with update: {example: …} |
A DELETE on a documented path |
….['/api/v1/invoices'].delete |
….['/api/v1/invoices'], with update: {delete: {…}} |
The one thing this pattern doesn’t stretch to is a whole new path. $.paths and the document root
are the two nodes that can’t carry an overlay’s provenance record, so targeting either produces a
document that fails schema validation (document.schema-invalid) rather than a new path. Synthesizing
paths is a DocumentTransformer job.
When they apply — and what wins
Section titled “When they apply — and what wins”Overlays are applied at assembly time, once your operations have been built, as the overlay(45)
layer in Docuccino’s precedence chain:
fallback(5) < inference(10) < integration(20) < docblock(30) < attribute(40) < overlay(45) < config(50)So an overlay beats anything inferred, integrated, or written as a docblock or attribute: on the nodes it targets, it has the last word. Because merging is field-by-field, an overlay can rewrite one field of an operation without discarding the rest of it.
Every value an overlay changes is recorded in that node’s provenance with producer: overlay, and
the value it replaced is captured under overrode — so x-docuccino always
answers “why is it documented this way?”, even for a hand-applied correction.
The target selector
Section titled “The target selector”Docuccino resolves a documented, predictable subset of JSONPath — enough to address any node in a real specification, with no ambiguous matches:
| Selector | Example | Matches |
|---|---|---|
| Root | $ |
The document root. |
| Dot member | $.info.title |
A named object member. Letters, digits, _ and - only. |
| Bracket member | $.paths['/api/v1/invoices'] |
A member whose key needs quoting — slashes, dots, spaces. |
| Array index | …parameters[0] |
An array element by position. |
| Equality filter | …parameters[?(@.name=='status')] |
Every list element whose string field equals a value. |
Bracket notation is what you’ll reach for most: path keys ('/api/v1/invoices'), status codes
(responses['200']) and media types (content['application/json']) all contain characters dot
notation can’t express.
Anything outside this subset — wildcards (*), recursive descent (..), array slices ([1:3]),
unions, or any comparison other than == — raises an error diagnostic rather than matching silently.
Diagnostics
Section titled “Diagnostics”Nothing about an overlay fails quietly. Every problem shows up in the build output, so
docuccino:export --fail-on=warning catches a rotten overlay in CI. The
diagnostics reference carries the same four alongside
every other code Docuccino emits:
| Code | Severity | Means |
|---|---|---|
overlay.invalid |
warning | The file couldn’t be parsed — a missing overlay: 1.0.x line, a non-list actions, or an action with no target or no operation. The whole file is skipped. |
overlay.target-missing |
warning | The target matched no node; that one action did nothing. |
overlay.unsupported-selector |
error | The target uses JSONPath outside the supported subset. |
overlay.conflicting-operation |
error | One action declares both update and remove. Split it in two. |
Worked examples
Section titled “Worked examples”Patch an operation you can’t edit
Section titled “Patch an operation you can’t edit”A billing package ships its own /oauth/token route. You can’t annotate its controller — but you can
document it. First let the route into the document (routes whose controller lives under vendor/ are
excluded by default):
documents: default: routes: { include: ['api/*', 'oauth/*'], include_vendor: true }Then give it a real summary, a description, and a usable example:
overlay: 1.0.0info: title: Billing route polish version: 1.0.0actions: - target: "$.paths['/oauth/token'].post" update: summary: Exchange credentials for an access token tags: [OAuth] description: | Issues a bearer token for the client-credentials and password grants. - target: "$.paths['/oauth/token'].post.responses['200'].content['application/json']" update: example: access_token: "eyJ0eXAiOiJKV1Qi…" token_type: Bearer expires_in: 3600"/oauth/token": { "post": { "summary": "Exchange credentials for an access token", "description": "Issues a bearer token for the client-credentials and password grants.\n", "tags": ["OAuth"], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object" }, "example": { "access_token": "eyJ0eXAiOiJKV1Qi…", "token_type": "Bearer", "expires_in": 3600 } } } } } }}Each update merges onto a node that already exists, so the operation’s inferred parameters, its
other responses, and the response schema are all left untouched. Note the second target: the
media-type object is what exists, so that’s where example is merged in.
Add an operation Docuccino never saw
Section titled “Add an operation Docuccino never saw”Sometimes a route answers a verb Docuccino can’t see — it’s dispatched outside the framework router, or served by an edge function in front of your app. If the path is already documented, you can add the operation to it:
overlay: 1.0.0info: title: Invoice voiding version: 1.0.0actions: - target: "$.paths['/api/v1/invoices/{invoice}']" description: Served by the billing gateway, not by this app's router. update: delete: summary: Void an invoice tags: [Invoices] responses: '204': description: The invoice was voided."/api/v1/invoices/{invoice}": { "get": { "…": "inferred, untouched" }, "delete": { "summary": "Void an invoice", "tags": ["Invoices"], "responses": { "204": { "description": "The invoice was voided." } } }}The merged operation is hand-authored: there’s no inference behind it, and no stable identity — so
keep it small and factual, because you’re describing a contract by hand rather than deriving it. If
you find yourself doing this for many operations, or you need a path that isn’t in the document at
all, step up to a DocumentTransformer.
Remove something internal from the published spec
Section titled “Remove something internal from the published spec”Inference documents what it finds, including the odd parameter or property you’d rather not publish.
Prune them with remove:
overlay: 1.0.0info: title: Trim internals version: 1.0.0actions: - target: "$.paths['/api/v1/invoices'].get.parameters[?(@.name=='debug_trace')]" remove: true - target: "$.components.schemas.Invoice.properties.internal_ledger_ref" remove: trueRemoving an array element re-indexes the array to a clean list, so a filtered-out parameter never leaves a gap — and a filter matching several elements removes them all, highest index first. For recurring, name-based hygiene across a whole document, the leakage lint is a better fit — it flags sensitive-looking properties everywhere they appear, rather than one target at a time.
Vary one response from a shared component
Section titled “Vary one response from a shared component”One error Data class serving every status is a good design, and Docuccino documents it as one
component every error response $refs. That’s exactly what you want — right up to the status that
carries a little more. A ProblemDetailsData used for 403, 404 and 422 has an errors member
that only 422 ever fills, so it’s #[Hidden]; now 422 under-documents itself.
#[Hidden] can’t help here: it’s document-wide, because a class is one component (see
#[Hidden] for the alternatives, and prefer them when you own
the class — a dedicated ValidationProblemData is honest on every status, and a renderer whose 422
branch builds a different body is documented as building one). When
you’d rather keep one class and correct the document, an overlay merges the extra member onto that one
response:
overlay: 1.0.0info: title: Validation errors on 422 version: 1.0.0actions: - target: "$.paths['/api/v1/invoices'].post.responses['422'].content['application/json']" description: ProblemDetailsData hides `errors`; only 422 carries it. update: schema: properties: errors: type: object additionalProperties: { type: array, items: { type: string } } required: [errors] - target: "$.paths['/api/v1/invoices/{invoice}'].put.responses['422'].content['application/json']" description: Same, for the update endpoint. update: schema: properties: errors: type: object additionalProperties: { type: array, items: { type: string } } required: [errors]"422": { "description": "Unprocessable Entity", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetailsData", "properties": { "errors": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } }, "required": ["errors"] } } }}The shared component is untouched — every other status still documents the class exactly as written.
The members sitting beside the $ref intersect with it, which is how OpenAPI 3.2 and 3.1 read a
schema (they’re JSON Schema 2020-12). Emitting OpenAPI 3.0 — where a $ref sibling is ignored —
Docuccino moves the $ref into an allOf wrapper so the added member survives, and tells you it did
with a downlevel.ref-siblings info diagnostic.
Two limits to plan around, both from the target selector:
- One action per operation. There are no wildcards and no recursive descent, so ten operations
that can
422need ten actions. That’s deliberate — every target names exactly what it changes, and a target that stops matching warns instead of quietly hitting something else. - Target the operation’s response, not the shared error component. When several operations state
the same error body, Docuccino hoists it into
components.schemas.UnprocessableEntityandcomponents.responses.UnprocessableEntity— but those hoists run after overlays, so either pointer matches nothing at overlay time and you’d get anoverlay.target-missingwarning. Patch the per-operation response, as above, and let the hoists run on the corrected document: the patched422s share one component and the untouched ones another, never one body merged wrong.
Past a handful of operations, stop writing actions and write a
DocumentTransformer instead: it runs after assembly and after overlays
with the finished document in hand, so “for every 422 whose schema $refs ProblemDetailsData, add
errors” becomes a few lines of PHP that never goes stale as routes come and go.
Document-wide changes
Section titled “Document-wide changes”An overlay targets specific nodes. When a change is really a policy — it should apply to every operation, or synthesize structure programmatically — reach past overlays:
- Config and representation policies cover the common cases declaratively: operation-id style, filter naming, nullable style, tag mapping, error-response strategy. See the configuration reference.
- A
DocumentTransformeris a whole-document post-processor that runs after assembly and overlays, with the finished document in hand. It’s the right tool when you need to touch many operations at once, or build structure from data — generating a set of paths from a manifest, for instance. Every official integration is built with the same extension points — see writing an integration.