Your endpoint definitions are your OpenAPI spec. No annotations, no decorators, no separate YAML to maintain — the schema you already write for routing and validation generates a full OpenAPI 3.1 document automatically.
API documentation drifts from implementation. Keeping OpenAPI specs up to date manually is error-prone. Annotation-based approaches couple documentation to source code in brittle ways.
Your endpoint definitions already describe routes, request bodies, query params, and response types. @cleverbrush/server-openapi reads those definitions and emits a complete OpenAPI 3.1 document — including $ref deduplication, security schemes, and discriminated unions. There is no separate spec to maintain — change the code, the spec updates itself.
generateOpenApiSpec() — produces a full OpenAPI 3.1 document.@cleverbrush/schema builders to JSON Schema Draft 2020-12.ParseStringSchemaBuilder templates both produce typed OpenAPI path parameters.securitySchemes automatically.tags: [{name, description?}] to annotate tag groups; tag names are also auto-collected from endpoint registrations.discriminator keyword is emitted automatically for tagged union schemas, enabling code generators (openapi-generator, orval) to produce proper typed variants.serveOpenApi() middleware and createOpenApiEndpoint() for runtime serving.writeOpenApiSpec() for build-time file generation.Add the serveOpenApi middleware before starting the server. The spec is lazily generated and cached on the first request to /openapi.json:
import { createServer, endpoint, route } from '@cleverbrush/server';
import { serveOpenApi } from '@cleverbrush/server-openapi';
import { object, string, number } from '@cleverbrush/schema';
const GetUser = endpoint
.get('/api/users', route({ id: number().coerce() })`/${t => t.id}`)
.summary('Get a user by ID')
.tags('users');
const server = createServer();
server
.use(serveOpenApi({
getRegistrations: () => server.getRegistrations(),
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }]
}))
.handle(GetUser, ({ params }) => ({ id: params.id }));
await server.listen(3000);
// GET /openapi.json → OpenAPI 3.1 documentPrefer registering the spec as a first-class endpoint so it appears in the spec itself and benefits from the same middleware pipeline:
import { createOpenApiEndpoint } from '@cleverbrush/server-openapi';
const { endpoint: openApiEp, handler } = createOpenApiEndpoint({
getRegistrations: () => server.getRegistrations(),
info: { title: 'My API', version: '1.0.0' }
});
server.handle(openApiEp, handler);Generate the spec to a file during your build pipeline or CI:
import { writeOpenApiSpec } from '@cleverbrush/server-openapi';
// Import your server registrations (without listening)
import { registrations } from './app';
await writeOpenApiSpec({
registrations,
info: { title: 'My API', version: '1.0.0' },
outputPath: './openapi.json'
});$ref Deduplication — Named SchemasCall .schemaName('Name') on any @cleverbrush/schema builder to mark it as a named component. generateOpenApiSpec() automatically extracts all named schemas into components/schemas and replaces every inline occurrence with a $ref pointer — eliminating repetition and producing cleaner specs.
import { object, string, number, array } from '@cleverbrush/schema';
import { endpoint, route } from '@cleverbrush/server';
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
// Export as a constant — reuse the same reference everywhere
export const UserSchema = object({
id: number(),
name: string().nonempty(),
}).schemaName('User');
const GetUser = endpoint
.get('/api/users', route({ id: number().coerce() })`/${t => t.id}`)
.returns(UserSchema);
const ListUsers = endpoint.get('/api/users').returns(array(UserSchema));
const spec = generateOpenApiSpec({
registrations: [GetUser.registration, ListUsers.registration],
info: { title: 'My API', version: '1.0.0' },
});
// ✅ UserSchema is emitted ONCE under components.schemas.User
// ✅ Both endpoints receive { "$ref": "#/components/schemas/User" }
// instead of repeating the full inline definitionNested named schemas inside request bodies and response objects are resolved automatically too:
const AddressSchema = object({
street: string(),
city: string(),
}).schemaName('Address');
// Wrapper is anonymous → inlined; nested Address → $ref
const CreateUserBody = object({ address: AddressSchema, name: string() });Conflict rule: registering two different schema instances under the same name throws during spec generation. Always export named schemas as constants and share the same object reference.
Pass the server's AuthenticationConfig to generate securitySchemes and per-operation security arrays automatically:
import { jwtScheme, authorizationCodeScheme } from '@cleverbrush/auth';
const authConfig = {
defaultScheme: 'jwt',
schemes: [
jwtScheme({ secret: '...', mapClaims: c => c }),
authorizationCodeScheme({
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
scopes: { 'read:items': 'Read items' },
authenticate: async (ctx) => ({ succeeded: false })
})
]
};
server.use(serveOpenApi({
getRegistrations: () => server.getRegistrations(),
info: { title: 'My API', version: '1.0.0' },
authConfig
}));
// JWT → securitySchemes.jwt: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }
// OAuth2 → securitySchemes.oauth2: { type: 'oauth2', flows: { authorizationCode: ... } }Self-referential schemas — tree nodes, nested menus, threaded comments — are supported via lazy() from @cleverbrush/schema. Call .schemaName() on the root and generateOpenApiSpec handles the rest: the schema is expanded once under components/schemas, and every recursive reference becomes a $ref pointer.
import { object, number, array, lazy } from '@cleverbrush/schema';
type TreeNode = { value: number; children: TreeNode[] };
const treeNode: ReturnType<typeof object> = object({
value: number(),
children: array(lazy(() => treeNode))
}).schemaName('TreeNode');
// Use treeNode as a body or response schema — no extra config needed:
// components.schemas.TreeNode → { type: 'object', properties: { children: { items: { $ref: '...' } } } }
// requestBody → { "$ref": "#/components/schemas/TreeNode" }Pre-fill the Try it out panel in Swagger UI by attaching examples to endpoints:
const CreateUser = endpoint
.post('/api/users')
.body(UserSchema)
.example({ name: 'Alice', email: 'alice@example.com' });
// Or provide named examples:
const CreateItem = endpoint
.post('/api/items')
.body(ItemSchema)
.examples({
minimal: { summary: 'Minimal', value: { name: 'Widget' } },
full: { summary: 'Complete', value: { name: 'Widget', price: 9.99 } }
});Schema-level examples set via .example(value) propagate to parameter and response schemas automatically.
Declare binary file responses with .producesFile() — the generated spec emits the correct binary content type instead of a JSON schema:
const ExportCsv = endpoint
.get('/api/export')
.producesFile('text/csv', 'CSV export');
const Download = endpoint
.get('/api/download')
.producesFile(); // defaults to application/octet-streamWhen both .returns() and .producesFile() are set, the binary response takes precedence.
Use .produces() to declare additional response content types for content-negotiated endpoints. The generated spec emits a multi-entry content map where each MIME type can optionally override the response schema:
const GetItems = endpoint
.get('/api/items')
.returns(object({ id: number(), name: string() }))
.produces({
'text/csv': {}, // reuses the JSON response schema
'application/xml': { schema: string() } // custom schema
});application/json is always included when a response schema is declared. When .producesFile() is also set, the binary response takes precedence.
Document response headers — pagination cursors, rate-limit counters, cache-control directives — with .responseHeaders(). Each property in the object schema becomes a named header entry in the OpenAPI spec, applied to every response code:
const GetItems = endpoint
.get('/api/items')
.returns(object({ id: number(), name: string() }))
.responseHeaders(object({
'X-Total-Count': number().describe('Total number of matching items'),
'X-Page': number().describe('Current page index'),
'X-Rate-Limit': number()
}));Property descriptions propagate to the OpenAPI description field on each header entry, making pagination and throttling contracts visible in Swagger UI and generated client SDKs.
OpenAPI supports a top-level tags array where each entry can carry a description and optional externalDocs. Pass a tags option to describe your tag groups:
generateOpenApiSpec({
registrations,
info: { title: 'My API', version: '1.0.0' },
tags: [
{
name: 'users',
description: 'User management endpoints',
externalDocs: { url: 'https://docs.example.com/users' }
},
{ name: 'orders', description: 'Order management endpoints' }
]
});When tags is omitted, unique tag names are automatically collected from all registered endpoints and emitted as name-only entries — Swagger UI and Redoc still group operations correctly. Any endpoint tag not covered by the explicit list is appended alphabetically.
Use route() to define typed path parameters. The generated spec converts them to OpenAPI {param} format with per-parameter JSON Schema:
import { endpoint, route } from '@cleverbrush/server';
import { number } from '@cleverbrush/schema';
// route() template → { name: 'id', in: 'path', schema: { type: 'number' } }
endpoint.get(
'/api/users',
route({ id: number().coerce() })`/${t => t.id}`
);Link external reference material to an operation with .externalDocs(url, description?). The generator emits an externalDocs object on the OpenAPI Operation Object:
const GetItems = endpoint
.get('/api/items')
.returns(ItemSchema)
.externalDocs('https://docs.example.com/items', 'Items API reference');Declare follow-up actions available from a response using .links(defs). Links are emitted under the primary 2xx response's links map. Parameters can be raw runtime expression strings or a type-safe callback where property accesses resolve to $response.body#/<pointer> expressions automatically:
const CreateUser = endpoint
.post('/api/users')
.body(object({ name: string(), email: string() }))
.returns(object({ id: number(), name: string(), email: string() }))
.links({
GetUser: {
operationId: 'getUser',
// Type-safe: accesses 'id' → resolves to '$response.body#/id'
parameters: (r) => ({ userId: r.id }),
},
});Document async out-of-band requests with .callbacks(defs). The callback URL can be a raw runtime expression string or a type-safe urlFrom selector that resolves a request body field to a {$request.body#/<pointer>} expression:
const Subscribe = endpoint
.post('/api/subscriptions')
.body(object({ callbackUrl: string(), events: array(string()) }))
.callbacks({
onEvent: {
urlFrom: (b) => b.callbackUrl, // → {$request.body#/callbackUrl}
method: 'POST',
summary: 'Event notification delivered to subscriber',
body: EventSchema,
},
});Document async webhook notifications your API sends to consumers. Use defineWebhook() and register via ServerBuilder.webhook(), then pass them to generateOpenApiSpec via the webhooks option. A top-level webhooks map is emitted in the OpenAPI 3.1 document:
import { defineWebhook } from '@cleverbrush/server';
const userCreated = defineWebhook('userCreated', {
method: 'POST',
summary: 'Fired when a new user registers',
body: object({ id: number(), email: string() }),
});
// Register with the server (for documentation only):
createServer().webhook(userCreated).handle(/* ... */);
// Or pass directly to the generator:
generateOpenApiSpec({ registrations, info, webhooks: [userCreated] });