OpenAPI 3.1 specification generation for @cleverbrush/server. Converts endpoint registrations, schema definitions, and authentication configuration into a fully-formed OpenAPI document — no annotations, no decorators. Also generates AsyncAPI 3.0 documents for WebSocket subscription endpoints.
generateOpenApiSpec()— converts@cleverbrush/serverendpoint registrations into an OpenAPI 3.1 document.generateAsyncApiSpec()— converts@cleverbrush/serverWebSocket subscription registrations into an AsyncAPI 3.0 document.serveAsyncApi()— middleware that lazily generates and caches the AsyncAPI spec; serves it at a configurable path (default:/asyncapi.json).- Schema conversion — maps
@cleverbrush/schemabuilders to JSON Schema Draft 2020-12 via@cleverbrush/schema-json. - Path resolution — converts both colon-style paths (
:id) andParseStringSchemaBuildertemplates to OpenAPI{param}format with per-parameter schemas. - Security mapping — translates
@cleverbrush/authauthentication schemes to OpenAPIsecuritySchemes; maps per-endpointauthorize()tosecurityarrays. Auto-detects OAuth 2.0 flows (authorizationCodeScheme,clientCredentialsScheme) and OpenID Connect (oidcScheme). - Top-level tags — pass
tags: [{ name, description?, externalDocs? }]toOpenApiOptions; tag names are also auto-collected from endpoint registrations. - Request body examples — emit
example/exampleson Media Type Objects via.example()and.examples()onEndpointBuilder. Schema-level examples propagate automatically. - Binary / file responses —
.producesFile(contentType?, description?)emits binary content types instead of JSON schemas for file download endpoints. - Multiple content types —
.produces({ 'text/csv': {}, 'application/xml': { schema } })emits a multi-entrycontentmap for content-negotiated endpoints; an optional per-type schema override is supported. - Response headers —
.responseHeaders(schema)documents response headers (X-Total-Count, rate-limit, cache-control, etc.) across every response code; each property becomes a named header entry with schema and description. - External docs —
.externalDocs(url, description?)attaches anexternalDocsobject to the OpenAPI Operation Object. - Links —
.links(defs)declares follow-up links from a response; emitted under the primary 2xx response'slinksmap. Parameters can be raw runtime expression strings or a type-safe callback(response) => Record<string, unknown>where property accesses are resolved to$response.body#/<pointer>expressions automatically. - Callbacks —
.callbacks(defs)declares async out-of-band callbacks on the Operation Object. The callback URL can be a rawexpressionstring or a type-safeurlFromcallback resolved from the request body schema via property descriptors. - Webhooks — pass
webhooks: [defineWebhook('name', options)]toOpenApiOptions(and register viaServerBuilder.webhook(def)) to emit a top-levelwebhooksmap in the OpenAPI document. serveOpenApi()— middleware that lazily generates and caches the spec; serves it at a configurable path (default:/openapi.json).createOpenApiEndpoint()— returns a typed endpoint + handler pair for use withServerBuilder.handle().- CLI / build script —
writeOpenApiSpec()writes the spec to a file.
npm install @cleverbrush/server-openapi @cleverbrush/server @cleverbrush/schemaimport { ServerBuilder, endpoint } from '@cleverbrush/server';
import { serveOpenApi } from '@cleverbrush/server-openapi';
import { object, string, number } from '@cleverbrush/schema';
const GetUser = endpoint
.get('/api/users/:id')
.summary('Get a user by ID')
.tags('users');
const server = new ServerBuilder();
server
.use(serveOpenApi({
server,
info: { title: 'My API', version: '1.0.0' }
}))
.handle(GetUser, ({ params }) => ({ id: params.id }));
await server.listen(3000);
// GET /openapi.json → OpenAPI 3.1 documentWhen server is provided, endpoint registrations, authentication config, and webhooks are derived automatically. You can still pass getRegistrations, authConfig, or webhooks explicitly to override any server-derived value.
import { serveOpenApi } from '@cleverbrush/server-openapi';
server.use(serveOpenApi({
server,
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com', description: 'Production' }],
path: '/openapi.json' // default
}));import { createOpenApiEndpoint } from '@cleverbrush/server-openapi';
const { endpoint: openApiEp, handler } = createOpenApiEndpoint({
server,
info: { title: 'My API', version: '1.0.0' }
});
server.handle(openApiEp, handler);import { writeOpenApiSpec } from '@cleverbrush/server-openapi';
await writeOpenApiSpec({
registrations: server.getRegistrations(),
info: { title: 'My API', version: '1.0.0' },
outputPath: './openapi.json'
});When the same schema definition is used by multiple endpoints, you can mark it with .schemaName() from @cleverbrush/schema so that generateOpenApiSpec() extracts it once into components/schemas and replaces every inline occurrence with a $ref pointer.
- Call
.schemaName('ComponentName')on any@cleverbrush/schemabuilder you want to extract. - Export the result as a constant and reuse the same reference wherever the schema is needed.
generateOpenApiSpec()detects all named schemas via a pre-pass walk, emits them undercomponents.schemas, and replaces inline definitions with$refpointers.
import { object, string, number, array } from '@cleverbrush/schema';
import { endpoint } from '@cleverbrush/server';
import { generateOpenApiSpec } from '@cleverbrush/server-openapi';
// Mark once — reuse everywhere
const UserSchema = object({
id: number(),
name: string(),
}).schemaName('User');
const GetUser = endpoint.get('/api/users/: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' }
});
// components.schemas.User → { type: 'object', properties: { id: …, name: … } }
// GET /api/users/:id → responses.200.content['application/json'].schema: { $ref: '#/components/schemas/User' }
// GET /api/users → responses.200.content['application/json'].schema: { type: 'array', items: { $ref: '…/User' } }Nested named schemas inside request bodies are also resolved:
const AddressSchema = object({ street: string(), city: string() }).schemaName('Address');
// The wrapper is anonymous — inlined. The nested AddressSchema → $ref.
const CreateUserBody = object({ address: AddressSchema, name: string() });Registering two different schema instances under the same name throws immediately during spec generation:
const A = object({ x: string() }).schemaName('Thing');
const B = object({ y: number() }).schemaName('Thing'); // different instance!
generateOpenApiSpec({ registrations: [...], info: { … } });
// Error: Schema name "Thing" is already registered by a different schema instance.Re-registering the same instance (because it appears in multiple endpoints) is a no-op.
SchemaRegistry and walkSchemas are also exported from @cleverbrush/server-openapi for custom tooling:
import { SchemaRegistry, walkSchemas } from '@cleverbrush/server-openapi';
const registry = new SchemaRegistry();
walkSchemas(MySchema, registry);
registry.getName(MySchema); // 'MyComponentName' | null
registry.entries(); // IterableIterator<[name, SchemaBuilder]>
registry.isEmpty; // booleanWhen a request body, response, or parameter schema is a discriminated union — all branches are objects sharing a required property with unique literal values — the generated spec automatically includes the OpenAPI discriminator keyword alongside anyOf.
If the union branches use .schemaName() and are extracted as $ref components, the discriminator also includes a mapping from each literal value to its $ref path:
const Cat = object({ type: string('cat'), name: string() }).schemaName('Cat');
const Dog = object({ type: string('dog'), breed: string() }).schemaName('Dog');
const PetBody = union(Cat).or(Dog);
const CreatePet = endpoint.post('/api/pets').body(PetBody);
// Generated spec:
// requestBody.content['application/json'].schema:
// {
// anyOf: [{ $ref: '#/components/schemas/Cat' }, { $ref: '#/components/schemas/Dog' }],
// discriminator: { propertyName: 'type', mapping: { cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog' } }
// }Code generators like openapi-generator and orval use the discriminator to produce proper tagged union types.
Self-referential schemas (tree nodes, nested menus, threaded comments) are
supported via lazy() from @cleverbrush/schema. Call .schemaName() on the
root schema and generateOpenApiSpec will:
- Register the named schema in
components.schemas, expanding its definition exactly once. - Replace every recursive reference inside the definition with the appropriate
$refpointer — breaking the cycle automatically.
import { object, number, array, lazy } from '@cleverbrush/schema';
type TreeNode = { value: number; children: TreeNode[] };
// TypeScript needs an explicit annotation for recursive types
const treeNode: ReturnType<typeof object> = object({
value: number(),
children: array(lazy(() => treeNode))
}).schemaName('TreeNode');
// Use treeNode as a body / response schema — no extra configuration needed
const CreateTree = endpoint.post('/api/tree').body(treeNode);Generated spec (abbreviated):
components:
schemas:
TreeNode:
type: object
properties:
value: { type: integer }
children:
type: array
items: { $ref: '#/components/schemas/TreeNode' }
paths:
/api/tree:
post:
requestBody:
content:
application/json:
schema: { $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' });Emits example on the OpenAPI Media Type Object:
requestBody:
content:
application/json:
schema: { ... }
example: { name: Alice, email: alice@example.com }const CreateUser = endpoint
.post('/api/users')
.body(UserSchema)
.examples({
minimal: { summary: 'Minimal', value: { name: 'Alice' } },
full: {
summary: 'Complete',
description: 'A fully populated user',
value: { name: 'Alice', email: 'alice@example.com', age: 30 }
}
});Examples attached directly to schemas via .example() propagate to parameter and response schemas in the generated spec:
const PageParam = number().example(1);
const UserResponse = object({ id: number(), name: string() }).example({ id: 1, name: 'Alice' });Use .producesFile() to declare that an endpoint returns a binary file instead of JSON. The generated spec emits the appropriate binary content type.
const ExportCsv = endpoint
.get('/api/export')
.producesFile('text/csv', 'CSV export');
const Download = endpoint
.get('/api/download')
.producesFile(); // defaults to application/octet-streamProduces:
responses:
'200':
description: CSV export
content:
text/csv:
schema: { type: string, format: binary }When both .returns() and .producesFile() are set, the binary response takes precedence.
When you pass the server option, authentication configuration is picked up automatically from server.getAuthenticationConfig(). Security schemes and per-operation security arrays are generated without any extra configuration:
import { jwtScheme } from '@cleverbrush/auth';
const server = new ServerBuilder()
.useAuthentication({
defaultScheme: 'jwt',
schemes: [jwtScheme({ secret: '...', mapClaims: c => c })]
})
.useAuthorization();
server.use(serveOpenApi({
server,
info: { title: 'My API', version: '1.0.0' }
}));You can also pass authConfig explicitly (useful when not using the server option):
server.use(serveOpenApi({
getRegistrations: () => server.getRegistrations(),
info: { title: 'My API', version: '1.0.0' },
authConfig: server.getAuthenticationConfig()
}));JWT schemes generate { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }; cookie schemes generate { type: 'apiKey', in: 'cookie' }.
OpenAPI supports a top-level tags array where each entry can carry a description and optional externalDocs. Pass a tags array to generateOpenApiSpec() (or any serving helper) to define them:
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 — so Swagger UI and Redoc still group operations correctly. Any endpoint tag not present in the explicit list is appended alphabetically.
const info: OpenApiInfo = {
title: 'My API',
version: '2.0.0',
description: 'Full description of my API.',
termsOfService: 'https://example.com/tos',
contact: { name: 'Support', email: 'support@example.com' },
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' }
};Both path styles are supported:
// Colon style — converted to {id}
endpoint.get('/api/users/:id');
// ParseStringSchemaBuilder — type-safe, with schema
import { route } from '@cleverbrush/server';
import { object, number } from '@cleverbrush/schema';
endpoint.get(route(object({ id: number().coerce() }), $t => $t`/api/users/${t => t.id}`));
// produces path "/api/users/{id}" with schema { type: 'number' }generateAsyncApiSpec() and serveAsyncApi() convert @cleverbrush/server WebSocket subscription registrations into an AsyncAPI 3.0 document — no annotations required.
import { serveAsyncApi } from '@cleverbrush/server-openapi';
server.use(serveAsyncApi({
server,
info: { title: 'My API', version: '1.0.0' },
// Optional: document the WebSocket servers
servers: {
production: { host: 'api.example.com', protocol: 'wss' },
local: { host: 'localhost:3000', protocol: 'ws' },
},
path: '/asyncapi.json', // default
}));
// GET /asyncapi.json → AsyncAPI 3.0 document (lazily generated, cached)import { generateAsyncApiSpec } from '@cleverbrush/server-openapi';
const spec = generateAsyncApiSpec({
subscriptions: server.getSubscriptionRegistrations(),
info: { title: 'My API', version: '1.0.0' },
servers: {
production: { host: 'api.example.com', protocol: 'wss' },
},
});
// write to file, validate, upload to AsyncAPI Studio, etc.
await fs.writeFile('asyncapi.json', JSON.stringify(spec, null, 2));Each subscription endpoint becomes:
- A channel (keyed by the subscription's
operationIdor a sanitised form of its path) with anaddresscontaining the WebSocket URL path. - A
sendoperation if the endpoint has an outgoing schema (server → client events). - A
receiveoperation if the endpoint has an incoming schema (client → server messages).
Named schemas (registered via .schemaName()) are collected into components.schemas and referenced via $ref pointers in the channel messages.
| Field | Type | Default | Description |
|---|---|---|---|
subscriptions |
readonly SubscriptionRegistration[] |
— | From server.getSubscriptionRegistrations() |
info |
AsyncApiInfo |
— | { title, version, description?, ... } |
servers |
Record<string, AsyncApiServerEntry> |
{} |
Named WebSocket server entries |
defaultHost |
string |
— | Fallback host when servers is empty |
BSD-3-Clause — see LICENSE.