@truxie/openapi
v0.1.3
Published
OpenAPI 3.1 document generator for truxie — renders the application's own route table, schemas and guards
Maintainers
Readme
@truxie/openapi
Renders a truxie application's route table as an OpenAPI 3.1 document.
npm install @truxie/openapiimport {generateOpenApiDocument} from '@truxie/openapi';
import {zodToJsonSchema} from '@truxie/zod';
import {requiredScopesOf} from '@truxie/rbac';
const spec = generateOpenApiDocument(app, {
info: {title: 'Acme API', version: '1.0.0'},
servers: [{url: 'https://api.acme.com'}],
toJsonSchema: zodToJsonSchema,
describeGuard: requiredScopesOf,
});A renderer, not a source
Every fact in the output already existed somewhere:
| In the spec | Comes from |
|---|---|
| paths, methods, path params | the router |
| query and body schemas | the decorators that already validate them |
| summary, description, responses, errors | @ApiDoc() |
| security requirements | the guards on the route |
| components.schemas | the ids your schemas already carry (.meta({id})) |
Nothing is declared twice, so nothing can drift from what the server actually does — which is the failure mode of every hand-maintained spec. Add a route and it appears; change a Zod schema and the spec changes with it.
Document the endpoint with @ApiDoc() from truxie core — the same decorator @truxie/mcp reads, so one description serves both people and agents:
@Get('/:id')
@ApiDoc({
summary: 'Fetch one user by id.',
returns: {schema: userSchema, description: 'The user.'},
errors: [{status: 404, when: 'no user has that id', then: 'list users to find a valid id'}],
related: ['GET /api/users'],
})
getOne(@Param('id') id: string) {}Choices worth knowing
documentedOnly defaults to true. Only routes carrying @ApiDoc() are emitted. Publishing the whole route table by default is how a spec ends up advertising internal endpoints nobody meant to announce.
No guards means no security block. Emitting [] would document the route as "authenticated, no particular permission needed" — a much stronger claim than "we could not tell".
related and examples are folded into the description. OpenAPI has no field for either, and dropping them would lose the two things a reader most often needs. @truxie/mcp renders the same data as its own sections.
Anything else gets an x- key. @ApiDoc({extensions}) is emitted verbatim onto the operation, so a fact the spec has no field for survives into the document instead of being lost at the renderer boundary:
@ApiDoc({summary: 'Delete a user permanently.', extensions: {'x-owner-team': 'billing'}})A key that does not start with x- is skipped rather than emitted — one stray key fails validation of the whole document, which is worse than losing the annotation. @truxie/mcp publishes its entire MCP-only half this way, as x-mcp-write, x-mcp-dangerous, x-mcp-idempotent, x-mcp-side-effects and friends, so a route exposed to an agent says so in the spec people read.
Handlers that validate by hand
A handler that reads @Query('from') and checks it in code records a key name and nothing else. request is where its input gets written down:
@Get('/sales')
@ApiDoc({
summary: 'Sales in a date range.',
request: {
query: z.object({
from: z.iso.date().describe('First day, inclusive.'),
granularity: z.enum(['day', 'month']).default('day'),
}),
},
})
list(@Query('from') from: string, @Query('granularity') granularity: string) {}Per part, the first that exists wins: an explicit override, the schema a param decorator recorded (it is the one that actually validates, so it cannot be wrong), request, then the bare keys @Query('k') plucks. request.contentType (one or several) and request.bodyRequired set the request body's media types and requiredness.
request is a second source of truth, so the generator checks it. onDrift is called per route and part whose documented keys disagree with the keys the handler reads — undocumented (read, not documented) and unread (documented, never read). Fail a test on it:
const issues: OpenApiDriftIssue[] = []
generateOpenApiDocument(app, {info, toJsonSchema: zodToJsonSchema, onDrift: (i) => issues.push(i)})
expect(issues).toEqual([])A handler that takes the whole @Query() object may read anything, so nothing is reported for it.
Each query and path property's description, example/examples and deprecated are lifted onto the parameter itself — that is where OpenAPI UIs look. Constraints (enum, default, formats) stay in the parameter's schema.
Responses
@ApiDoc({
summary: 'Sales as CSV.',
returns: {
contentType: 'text/csv',
schema: {type: 'string'},
headers: {'Content-Disposition': {description: 'Suggested file name.'}},
},
errors: [{status: 422, when: 'the range is invalid', then: 'fix `from`/`to`', schema: fieldErrors}],
})contentType defaults to application/json. A header's schema defaults to a string. An error with a schema or example gets a JSON body.
Schemas are converted from the side they describe: toJsonSchema(schema, {io: 'input'}) for requests, {io: 'output'} for responses. z.number().default(1) is optional to send and always present when received, and the spec says both. A one-argument converter still works.
Shared components
A Zod schema with .meta({id: 'SaleRow'}) becomes components.schemas.SaleRow, and every use of it a $ref — including a response whose top-level schema carries the id. $defs are hoisted, #/$defs/… refs rewritten, $schema dropped, and unreferenced components pruned.
When one id is used on both sides and the two actually differ (a defaulted field, additionalProperties), the response keeps the name and the request side becomes SaleRowInput. Two DIFFERENT schemas claiming one name is an error naming both routes — one of them would otherwise silently be documented with the other's shape.
Security
describeGuard attaches a guard's scopes to the first security scheme. For more than one way in, use securityFor: each guard returns its alternatives, or null when it says nothing about authentication.
securitySchemes: {jwt: {type: 'http', scheme: 'bearer'}, apiKey: {type: 'apiKey', in: 'header', name: 'x-api-key'}},
securityFor: (guard) =>
guard === JwtOrApiKeyGuard ? [{jwt: []}, {apiKey: []}] :
guard === SalesReadGuard ? [{jwt: ['sales:read']}] :
null,Guards are ANDed and alternatives ORed, so the route above gets [{jwt: ['sales:read']}, {apiKey: [], jwt: ['sales:read']}]. A route none of whose guards answered still gets no security block — unknown, not public. @NoGuard() routes do not inherit their controller's guards here either.
@ApiDoc({security}) overrides the derivation, and security: [] says explicitly public — for a webhook authenticated by a signature, say.
Options
| Option | Default | |
|---|---|---|
| info | — | Title and version. |
| servers | — | Base URLs. |
| externalDocs | — | Document-level external docs. |
| toJsonSchema | — | Schema → JSON Schema. Pass zodToJsonSchema. A schema already written as JSON Schema passes through untouched. |
| describeGuard | — | Guard → required scopes. Pass requiredScopesOf. Ignored when securityFor is given. |
| securityFor | — | Guard → alternative requirement objects, or null. |
| securitySchemes | bearer JWT as bearerAuth | With describeGuard, the first name is the one operations reference. |
| documentedOnly | true | Include routes with no @ApiDoc(). |
| exclude | — | Drop routes by method and path. |
| include | — | Keep only operations it accepts; sees {method, path, tags}. Applied after exclude — render a public subset of the same app. |
| onDrift | — | Called per route whose request keys disagree with the keys its handler reads. |
| tags | — | Declared tags, in order, with descriptions and x- keys. Undeclared tags found on operations follow. |
| tagGroups | — | Emitted as x-tagGroups (Redoc, Scalar). |
| extensions | — | Document-level x- keys; any other key is dropped. |
