openapi plugin walks every registered route and uses the Zod schemas you already passed to request, query, params, and response to produce the paths, parameters, requestBody, and responses sections for you..use(openapi({ path: '/openapi.json' })) and every other route registered on the app (before or after the plugin) is picked up automatically when a client requests that path:123456789101112131415161718192021222324252627282930import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' import { z } from 'zod' export const app = new Spiceflow() .use( openapi({ path: '/openapi.json', info: { title: 'My API', version: '1.0.0', }, }), ) .route({ method: 'GET', path: '/users/:id', params: z.object({ id: z.string() }), response: z.object({ id: z.string(), name: z.string(), }), handler({ params }) { return { id: params.id, name: 'Alice' } }, }) const schema = await app .handle(new Request('http://localhost/openapi.json')) .then((res) => res.json())
OpenAPIV3.Document you can hand off to Swagger UI, Redoc, Fern, Stainless, or any other OpenAPI tool.400.request.json(), query, and params are typed from the same schemas.requestBody, parameters, and responses are emitted directly from the schemas.12345678910111213141516171819202122232425262728293031323334import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' import { z } from 'zod' export const app = new Spiceflow() .use(openapi({ path: '/openapi.json' })) .route({ method: 'POST', path: '/users/:orgId', params: z.object({ orgId: z.string() }), query: z.object({ notify: z.boolean().optional(), }), request: z.object({ name: z.string(), email: z.string().email(), }), response: z.object({ id: z.string(), name: z.string(), email: z.string(), }), async handler({ params, query, request }) { // params is typed as { orgId: string } // query is typed as { notify?: boolean } // body is typed as { name: string, email: string } const body = await request.json() return { id: 'usr_123', name: body.name, email: body.email, } }, })
Always prefer.route()over.get()/.post()for public APIs. The.route()form takes every schema key (request,response,query,params,detail) in a single object, which is easier to discover and keeps the schemas attached to the handler where they belong. Use.get()/.post()only for quick internal routes that do not need validation or OpenAPI.
response as an object keyed by HTTP status codes instead of a single schema. Each entry becomes its own entry in the emitted OpenAPI responses object:12345678910111213141516171819202122232425262728293031323334import { z } from 'zod' import { Spiceflow, json } from 'spiceflow' import { openapi } from 'spiceflow/openapi' const ErrorShape = z.object({ error: z.string(), code: z.string(), }) export const app = new Spiceflow() .use(openapi({ path: '/openapi.json' })) .route({ method: 'GET', path: '/users/:id', params: z.object({ id: z.string() }), response: { 200: z.object({ id: z.string(), name: z.string(), }), 404: ErrorShape, 500: ErrorShape, }, handler({ params }) { const user = findUser(params.id) if (!user) { throw json( { error: 'not found', code: 'NOT_FOUND' }, { status: 404 }, ) } return { id: user.id, name: user.name } }, })
json(body, init) is a type-safe wrapper around Response.json() — it sets content-type: application/json automatically and carries the data type and status code through the type system. Prefer json() over Response.json() so the fetch client gets typed error responses.createSpiceflowFetch reads this map to produce a discriminated error type per status code. See Preserving Client Type Safety below.onError500 for unexpected failures, a 401 for missing auth, maybe a 400 for validation. Spiceflow has a centralized onError handler that runs for every thrown error, which pairs naturally with a shared response object you spread into each route.1234567891011121314// src/api/shared-responses.ts import { z } from 'zod' export const ErrorResponse = z.object({ error: z.string(), code: z.string(), requestId: z.string().optional(), }) // Shared response entries that every route should document. export const commonResponses = { 500: ErrorResponse, 401: ErrorResponse, } as const
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051// src/api/app.ts import { Spiceflow, json } from 'spiceflow' import { openapi } from 'spiceflow/openapi' import { z } from 'zod' import { commonResponses, ErrorResponse } from './shared-responses' export const app = new Spiceflow() .use(openapi({ path: '/openapi.json' })) .onError(({ error, request }) => { console.error('[api]', request.url, error) return json( { error: error.message || 'internal server error', code: 'INTERNAL', }, { status: 500 }, ) }) .route({ method: 'GET', path: '/users/:id', params: z.object({ id: z.string() }), response: { ...commonResponses, 200: z.object({ id: z.string(), name: z.string() }), 404: ErrorResponse, }, handler({ params }) { const user = findUser(params.id) if (!user) { throw json( { error: 'not found', code: 'NOT_FOUND' }, { status: 404 }, ) } return user }, }) .route({ method: 'POST', path: '/users', request: z.object({ name: z.string(), email: z.string().email() }), response: { ...commonResponses, 200: z.object({ id: z.string() }), }, async handler({ request }) { const body = await request.json() return { id: 'usr_' + body.name } }, })
500 and 401 responses without duplicating the schema, and the actual 500 body is produced by a single onError handler at the app root. If you later change the error shape, you only edit shared-responses.ts.Spread...commonResponsesfirst, then list route-specific entries. Later keys win in object spread, so putting the shared responses at the top lets an individual route override, say, the shared500with a more specific schema if it ever needs to — and leaves the route-specific entries like200and404visually grouped at the bottom.
User, Organization, PaginatedList<T> — define the Zod schema once in a dedicated file and import it. Do not inline the same object in multiple routes; besides the duplication, you lose the ability to rename a field in one place.12345678910111213141516// src/schemas.ts import { z } from 'zod' export const User = z.object({ id: z.string(), name: z.string(), email: z.string().email(), createdAt: z.string().datetime(), }) export type User = z.infer<typeof User> export const UserList = z.object({ items: z.array(User), nextCursor: z.string().nullable(), })
1234567891011121314151617181920212223242526// src/api/users.ts import { Spiceflow } from 'spiceflow' import { z } from 'zod' import { User, UserList } from '../schemas' import { commonResponses } from './shared-responses' export const usersApi = new Spiceflow({ basePath: '/users' }) .route({ method: 'GET', path: '/', query: z.object({ cursor: z.string().optional() }), response: { ...commonResponses, 200: UserList }, handler: () => ({ items: [], nextCursor: null }), }) .route({ method: 'GET', path: '/:id', params: z.object({ id: z.string() }), response: { ...commonResponses, 200: User }, handler: ({ params }) => ({ id: params.id, name: 'Alice', email: 'alice@example.com', createdAt: new Date().toISOString(), }), })
src/schemas.ts file (or wherever you keep domain types) and be imported everywhere it is used. Keep it as one flat file until it actually grows too large — a single schemas.ts is easier to navigate than a schemas/ folder with one file per type.detail: { hide: true } on any route you do not want to appear in the generated OpenAPI document. This is useful for internal health checks, debug endpoints, and routes that exist purely for server-to-server communication:1234567891011121314151617import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' export const app = new Spiceflow() .use(openapi({ path: '/openapi.json' })) .route({ method: 'GET', path: '/health', detail: { hide: true }, handler: () => ({ ok: true }), }) .route({ method: 'POST', path: '/internal/reindex', detail: { hide: true }, handler: () => ({ queued: true }), })
detail field accepts anything from the OpenAPI operation object, so you can attach summary, description, tags, operationId, and any vendor extension. For anything longer than a single line, write the description as a real multi-line string using the string-dedent package so your code stays readable and the leading indentation is stripped automatically.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849import { Spiceflow } from 'spiceflow' import { openapi } from 'spiceflow/openapi' import { z } from 'zod' import dedent from 'string-dedent' import { User } from '../schemas' import { commonResponses } from './shared-responses' export const app = new Spiceflow() .use(openapi({ path: '/openapi.json' })) .route({ method: 'POST', path: '/users', request: z.object({ name: z.string(), email: z.string().email(), }), response: { ...commonResponses, 200: User }, detail: { summary: 'Create a user', tags: ['users'], description: dedent` Creates a new user in the current organization. ## Behavior - The \`email\` field must be unique across the organization. If it already exists, the endpoint returns \`409 Conflict\`. - The returned \`id\` is a stable prefix-encoded identifier (\`usr_\` prefix + 24 base32 characters). ## Example \`\`\`bash curl -X POST https://api.example.com/users \\ -H 'content-type: application/json' \\ -d '{ "name": "Alice", "email": "alice@example.com" }' \`\`\` `, }, async handler({ request }) { const body = await request.json() return { id: 'usr_abc', name: body.name, email: body.email, createdAt: new Date().toISOString(), } }, })
openapi.json file checked into the repo — for SDK generation, for docs sites, or for diffing the schema on every PR. The cleanest way to produce one is to call app.handle() directly instead of spinning up a real HTTP server:1234567891011// scripts/generate-openapi.ts import { writeFile } from 'node:fs/promises' import { app } from '../src/api/app' const response = await app.handle( new Request('http://localhost/openapi.json'), ) const schema = await response.json() await writeFile('openapi.json', JSON.stringify(schema, null, 2)) console.log('Wrote openapi.json')
123tsx scripts/generate-openapi.ts # or bun scripts/generate-openapi.ts
123456{ "scripts": { "openapi": "tsx scripts/generate-openapi.ts", "prebuild": "pnpm openapi" } }
createSpiceflowFetch against the app instance (no real network, no server) — see the fetch client docs for a worked example.response directly affects what the client sees.json(). Use json() from spiceflow instead of Response.json() — it preserves the data type and status code in the type system, so TypeScript validates that the status exists in the response schema and the body shape matches. Response.json() erases all type information.Data, and errors are delivered as a SpiceflowFetchError that instanceof Error narrows away.123456789101112131415161718192021222324252627282930313233// src/api/app.ts import { Spiceflow, json } from 'spiceflow' import { z } from 'zod' const NotFound = z.object({ error: z.literal('not found') }) const Forbidden = z.object({ error: z.literal('forbidden'), reason: z.string() }) export const app = new Spiceflow().route({ method: 'GET', path: '/users/:id', params: z.object({ id: z.string() }), response: { 200: z.object({ id: z.string(), name: z.string() }), 403: Forbidden, 404: NotFound, }, handler({ params }) { if (params.id === 'banned') { throw json( { error: 'forbidden', reason: 'account suspended' }, { status: 403 }, ) } const user = findUser(params.id) if (!user) { throw json({ error: 'not found' }, { status: 404 }) } // Returned directly — the fetch client will type this as the success case only. return { id: user.id, name: user.name } }, }) export type App = typeof app
Error | Data union. The error half is typed from the status-code response map: for each non-200 status you defined, the client gets a SpiceflowFetchError<status, body> with the exact body shape that status returns.1234567891011121314151617181920212223242526// src/client.ts import { createSpiceflowFetch } from 'spiceflow/client' const api = createSpiceflowFetch('https://api.example.com') const result = await api('/users/:id', { params: { id: 'abc' } }) if (result instanceof Error) { // result is typed as // | SpiceflowFetchError<403, { error: 'forbidden'; reason: string }> // | SpiceflowFetchError<404, { error: 'not found' }> switch (result.status) { case 403: // result.value is { error: 'forbidden'; reason: string } console.error('Forbidden:', result.value.reason) break case 404: // result.value is { error: 'not found' } console.error('User not found') break } return } // result is typed as { id: string; name: string } — no error shape leaked in console.log('User:', result.id, result.name)
result.id and result.name are available without null checks or type narrowing on error fields.response map, TypeScript forces you to handle it on the client.responses object in openapi.json, so your SDK consumers see exactly the same contract.SpiceflowFetchError<number, any> for the error branch — which still works, but loses per-status typing. Add status entries to response whenever you care about the failure shape.