@cleverbrush/server-openapi

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.

$npm install @cleverbrush/server-openapi @cleverbrush/server

Requires @cleverbrush/schema and @cleverbrush/schema-json as peer dependencies.

💡 Why @cleverbrush/server-openapi?

The Problem

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.

The Solution

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.

Key Features

  • generateOpenApiSpec() — produces a full OpenAPI 3.1 document.
  • Schema conversion — maps @cleverbrush/schema builders to JSON Schema Draft 2020-12.
  • Path resolution — colon-style and ParseStringSchemaBuilder templates both produce typed OpenAPI path parameters.
  • Security mapping — JWT, cookie, OAuth 2.0, and OpenID Connect auth schemes become securitySchemes automatically.
  • Top-level tags — pass tags: [{name, description?}] to annotate tag groups; tag names are also auto-collected from endpoint registrations.
  • Discriminated unions — the OpenAPI 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.

Quick Start — serve at runtime

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 document

Serve as a Typed Endpoint

Prefer 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);

Build-Time File Generation

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 Schemas

Call .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 definition

Nested 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.

Security Schemes from Auth Config

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: ... } }

Recursive / Self-Referential Schemas

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" }

Request Body Examples

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.

File Download Responses

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-stream

When both .returns() and .producesFile() are set, the binary response takes precedence.

Multiple Content Types

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.

Response Headers

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.

Top-Level Tags with Descriptions

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.

Path Parameters

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}`
);

External Documentation

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');

Response Links

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 }),
        },
    });

Callbacks

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,
        },
    });

Webhooks

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] });