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() });Ordinary use-site modifiers retain the canonical named component:
const History = object({
current: UserSchema,
previous: UserSchema.optional().nullable().describe('Previous user')
});
// One User component; previous composes local nullability and annotations.
const PatchUser = UserSchema.partial(); // unnamed, exported inline
const PublicUser = UserSchema.omit('id').schemaName('PublicUser'); // new componentPresence, nullability, annotations and type-only modifiers preserve the original
definition. Shape/rule edits, callbacks, defaults, fallbacks and extension changes
discard inherited names. Their nested named children still reuse components.
Apply schemaName after these edits when the result needs its own component;
later optionality or annotations do not reconnect an unnamed derivative.
See the complete naming rules.
Registering two independent definitions under the same name throws immediately during spec generation, even if their shapes match:
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 or its use-site modifiers is a no-op. Calling
schemaName explicitly establishes a new independent definition, even on a
modified use; reusing an already registered name then conflicts.
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: '[email protected]' });Emits example on the OpenAPI Media Type Object:
requestBody:
content:
application/json:
schema: { ... }
example: { name: Alice, email: [email protected] }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: '[email protected]', 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: '[email protected]' },
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.
AsyncAPI uses the same canonical-reference, local-modifier and name-conflict rules as OpenAPI. Named recursive schemas are expanded once per component, with recursive uses referencing that component.
| 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.
.upload(object({ images: array(file()).minLength(1), cover: file().optional() }))
emits a multipart object with a binary images array and an optional binary
cover property. Required fields and array bounds come from the same schema used
by the server and typed client. File-only endpoints emit a request body even
without .body(). When a text-body schema exists, its properties are combined
with the file properties in the multipart schema.
Object response schemas explicitly using .acceptUnknownProps() remain open
in the generated schema, including responses containing JSON document fields.