@codewithagents/openapi-server
v2.2.1
Published
Generate typed server-side service interfaces and framework routers from OpenAPI 3.1
Maintainers
Readme
@codewithagents/openapi-server
@codewithagents/openapi-server generates a type-safe TypeScript service interface from your OpenAPI 3.1 spec, plus an optional router for Hono, Express, or Fastify, so your server implementation stays in sync with your API contract automatically.
- Framework-agnostic service interface:
service.tsis a plain TypeScript interface with no framework imports. Implement it however you want: Hono, Express, Fastify, Koa, plainhttp, Bun, Deno, or anything else. - Optional router scaffolding: set
"framework"to"hono","express", or"fastify"and get a ready-to-mount router as a starting point. Set"framework": "none"(the default) and wire the interface yourself. The generated code only ever imports what you already have. - Type-safe contract: the compiler tells you if your implementation drifts from the spec. Add an endpoint in the spec and forget to implement it. TypeScript fails the build.
- Prettier-clean output: every generated file passes
prettier --checkout of the box. - OpenAPI 3.x: 3.1.x is the primary target; 3.0.x is supported in practice (8 of the 13 showcase specs are 3.0.x and all compile under
tsc --strict). Full support for$ref,allOf,anyOf,oneOf,nullable. - TypeScript strict mode: all output passes
strict: true.
Install
pnpm add -D @codewithagents/openapi-server
# or
npm install -D @codewithagents/openapi-serverRequires openapi-zod-ts. Run both generators together.
v2: the Fastify router now ships as a
FastifyPluginAsyncZodfactory.createRouter(service)returns a plugin you mount withfastify.register(createRouter(service), { prefix }), with nativefastify-type-provider-zodvalidation. See the CHANGELOG for the full v2 surface.
Quick start
1. Create openapi-server.config.json in your project root:
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono"
}2. Run the generator:
npx openapi-server3. Files appear in ./generated/:
| File | What it contains |
|---|---|
| service.ts | TypeScript interface, one method per API operation |
| router.ts | createRouter(service) factory for your chosen framework (hono, express, or fastify). The Quick start below uses Hono as one example. |
| _shared/errors.ts | Shared HttpError class (see below) |
Run openapi-zod-ts first (or together) so models.ts exists before service.ts imports from it:
npx openapi-zod-ts && npx openapi-serverOr add to package.json:
{
"scripts": {
"generate": "openapi-zod-ts && openapi-server"
}
}Generated output
Given the petstore spec (GET /pets, POST /pets, GET /pets/{id}, DELETE /pets/{id}):
generated/service.ts (Hono/Express)
// This file is auto-generated. Do not edit manually.
import type { CreatePetRequest, Pet } from './models.js'
export interface PetstoreService {
/** GET /pets */
listPets(params?: { species?: string }): Promise<Pet[]>
/** POST /pets */
createPet(body: CreatePetRequest): Promise<Pet>
/** GET /pets/{id} */
getPet(id: string): Promise<Pet>
/** DELETE /pets/{id} */
deletePet(id: string): Promise<void>
}generated/service.ts (Fastify, framework: "fastify")
For Fastify, every service method receives a single required input object whose keys mirror the request (only the facets the operation actually declares are present):
// This file is auto-generated. Do not edit manually.
import type { CreatePetRequest, Pet } from './schema-types.js'
export interface PetstoreService {
/** GET /pets */
listPets(input: { query: { species?: string } }): Promise<Pet[]>
/** POST /pets */
createPet(input: { body: CreatePetRequest }): Promise<Pet>
/** GET /pets/{id} */
getPet(input: { params: { id: string } }): Promise<Pet>
/** DELETE /pets/{id} */
deletePet(input: { params: { id: string } }): Promise<void>
}The input object can have up to five facet keys: params, body, query, headers, cookies. Each facet key is required (no ?) when present; per-field optionality lives inside the facet (e.g. query: { species?: string }). Operations with no request data emit no input param at all.
generated/router.ts
// This file is auto-generated. Do not edit manually.
import { Hono } from 'hono'
import type { CreatePetRequest } from './models.js'
import type { PetstoreService } from './service.js'
import { HttpError } from './_shared/errors.js'
export { HttpError } from './_shared/errors.js'
export function createRouter(service: PetstoreService): Hono {
const app = new Hono()
app.get('/pets', async (c) => {
const params = {
species: c.req.query('species') ?? undefined
}
return c.json(await service.listPets(params))
})
// ... more routes
return app
}generated/_shared/errors.ts
// This file is auto-generated. Do not edit manually.
export class HttpError extends Error {
constructor(public readonly status: number, message: string) {
super(message)
this.name = 'HttpError'
}
}The router handles:
- Path params:
{id}→:id(Hono style), extracted viac.req.param() - Query params: extracted and typed (
string,number,boolean) - Request bodies: parsed via
c.req.json<T>()with the correct model type - Response status:
200for GET,201for POST,204for DELETE, derived from your spec
Implementing the service
Create a file that satisfies the generated interface. The compiler enforces the contract.
Hono / Express service implementation:
// src/server/petService.ts
import { randomUUID } from 'node:crypto'
import type { PetstoreService } from '../generated/service.js'
import type { Pet } from '../generated/models.js'
const pets = new Map<string, Pet>()
export const petService: PetstoreService = {
async listPets(params) {
const all = Array.from(pets.values())
if (params?.species) {
return all.filter(p => p.species.toLowerCase() === params.species!.toLowerCase())
}
return all
},
async createPet(body) {
const pet: Pet = { id: randomUUID(), ...body }
pets.set(pet.id, pet)
return pet
},
async getPet(id) {
const pet = pets.get(id)
if (!pet) throw new Error(`Pet ${id} not found`)
return pet
},
async deletePet(id) {
pets.delete(id)
},
}Fastify service implementation (uses the single input object shape):
// src/server/petService.ts
import { randomUUID } from 'node:crypto'
import type { PetstoreService } from '../generated/service.js'
import type { Pet } from '../generated/schema-types.js'
import { HttpError } from '../generated/router.js'
const pets = new Map<string, Pet>()
export const petService: PetstoreService = {
async listPets(input) {
const all = Array.from(pets.values())
const { species } = input.query
if (species) {
return all.filter(p => p.species.toLowerCase() === species.toLowerCase())
}
return all
},
async createPet(input) {
const pet: Pet = { id: randomUUID(), ...input.body }
pets.set(pet.id, pet)
return pet
},
async getPet(input) {
const pet = pets.get(input.params.id)
if (!pet) throw new HttpError(404, `Pet ${input.params.id} not found`)
return pet
},
async deletePet(input) {
pets.delete(input.params.id)
},
}For Fastify, each method receives its request data through the input object. Only the facets the operation declares are present: params for path params, body for request body, query for query params, headers for header params, cookies for cookie params.
The interface is re-generated every time the spec changes. If you add an endpoint in the spec and forget to implement it, TypeScript will tell you at compile time.
Wiring it up
Mount the generated router on a Hono app and serve it:
// src/server/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
// Mount API routes at /api
const apiRouter = createRouter(petService)
app.route('/api', apiRouter)
serve({ fetch: app.fetch, port: 3001 })createRouter returns a plain Hono instance. You can mount it at any path prefix, add middleware before or after, or nest it inside a larger app.
Config reference
openapi-server.config.json:
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts",
"context_type": "RequestContext",
"shared_output": "./shared"
}| Field | Required | Default | Description |
|---|---|---|---|
| input_openapi | Yes | n/a | Path to OpenAPI 3.x spec |
| output | Yes | n/a | Directory to write service.ts and router.ts |
| framework | No | "none" | Router framework to generate: "hono", "express", "fastify", or "none". Use "none" to generate only service.ts |
| input_schema | No | none | Path to user-owned Zod schema file. Enables server-side request validation (see below) |
| context_type | No | none | TypeScript type name to thread through service methods as a final ctx argument. See below. |
| emit_response_validation | No | false | Fastify only. Synthesize inline Zod response schemas for flat inline schemas. See below. |
| shared_output | No | derived | Override directory for _shared/errors.ts. Default: derived from output paths (see below). |
Use --config <path> to point at a config file in a different location:
npx openapi-server --config ./config/openapi-server.config.jsonRelative paths in the config resolve from the config file's directory.
Zod request validation (input_schema)
See the Zod validation section in the docs for the two-pass generation flow and schema naming convention.
Point input_schema at the same schemas.ts you use with openapi-zod-ts. The server generator adds runtime validation to every route that receives a request body:
Config:
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts"
}Generated router with validation:
app.post('/pets', async (c) => {
const body = await c.req.json()
const parseResult = CreatePetRequestSchema.safeParse(body)
if (!parseResult.success) {
return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422)
}
return c.json(await service.createPet(parseResult.data), 201)
})Invalid requests get a structured 422 response instead of reaching your service implementation:
{
"error": "Invalid request body",
"issues": [
{ "code": "too_small", "path": ["name"], "message": "Name is required" }
]
}Fastify validation is native. The "fastify" router does not hand-roll the safeParse block shown above. It registers fastify-type-provider-zod once and attaches each operation's Zod schemas to the route (schema: { body, querystring, params, headers, response }), so Fastify validates and types requests through its own pipeline. Consequences specific to Fastify:
- Validation failures return Fastify's native
400(FST_ERR_VALIDATION), not the422 { error, issues }envelope that Hono and Express emit. Reshape it with a FastifysetErrorHandlerif you want a different contract. - Responses are validated against the declared response schema via the serializer compiler.
- Handlers are fully typed:
req.body,req.query, andreq.paramsare inferred from the Zod schemas (no manual generics or casts in your code). - Requires
fastifyandfastify-type-provider-zodin your owndependencies. - The router registers one
setErrorHandlerthat maps the exportedHttpErrorto its status; other errors are re-thrown to your app-level handler. - Cookie params (
in: cookie) are the exception: Fastify has no native cookie schema, so those keep a_ckvsafeParse block and still return422 { error: 'Invalid request cookies', issues }(see Cookie parameter validation).
Same schemas, both sides of the wire: openapi-zod-ts validates outgoing requests in the browser; openapi-server validates incoming requests on the server. One schemas.ts, one source of truth.
Drift detection: if schemas diverge from the spec (extra schema, missing schema), the generator warns to stderr. Builds still succeed; the warning is advisory.
Framework support
service.ts has no framework imports at all. It is always generated, always framework-agnostic, and works with anything.
router.ts is optional and supports:
| Value | What you get |
|---|---|
| "none" | Only service.ts. Wire the interface yourself. |
| "hono" | service.ts + a ready-to-mount router.ts using Hono. Includes optional Zod request validation via input_schema. |
| "express" | service.ts + a ready-to-mount router.ts using Express Router. Apply express.json() middleware before mounting. |
| "fastify" | service.ts + a router.ts that returns a FastifyPluginAsyncZod factory via createRouter(service). Mount with app.register(createRouter(service), { prefix }). Uses fastify-type-provider-zod for native request/response validation. Requires fastify and fastify-type-provider-zod in your dependencies. |
The framework package must be in your own dependencies. This package adds nothing at runtime.
Mounting patterns:
// Hono
app.route('/api', createRouter(service))
// Express
app.use(express.json())
app.use('/api', createRouter(service))
// Fastify: createRouter(service) returns a FastifyPluginAsyncZod; mount it with register
fastify.register(createRouter(service), { prefix: '/api' })The "none" path is always available and keeps the zero-footprint promise: the generated code has no runtime dependencies that you did not already choose.
Cookie parameter validation
Operations that declare in: cookie parameters get the same Zod validation treatment as header and query params. The generator reads the cookie name and schema constraints (required, enum, minLength, maxLength, pattern) from the spec and emits a _ckv safeParse block in the generated route handler. Failures return 422 { error: 'Invalid request cookies', issues }.
Cookie names are case-sensitive (unlike HTTP headers, which are always lowercased before lookup). The exact name from the spec is used for both the Zod field key and the value lookup.
Cookies are not forwarded to the service method signature. They are validated in the router layer only. Forwarding cookies to service methods is out of scope for the current release.
Per-framework plugin requirements:
| Framework | Required plugin / middleware | Cookie accessor |
|---|---|---|
| Fastify | @fastify/cookie registered before the router | req.cookies['name'] |
| Express | cookie-parser middleware applied before mounting the router | req.cookies['name'] |
| Hono | hono/cookie (imported automatically in the generated output) | getCookie(c, 'name') |
Fastify setup:
import fastifyCookie from '@fastify/cookie'
fastify.register(fastifyCookie)
fastify.register(createRouter(service), { prefix: '/api' })Express setup:
import cookieParser from 'cookie-parser'
app.use(cookieParser())
app.use('/api', createRouter(service))Hono setup:
No extra setup needed. The generator automatically adds import { getCookie } from 'hono/cookie' to the generated router when the spec declares cookie params. hono/cookie ships with Hono; no additional install is required.
Error handling
HttpError is the canonical way to return structured HTTP errors from service methods. The generated router.ts re-exports it, so you always import from one place:
import { HttpError } from './generated/router.js'This import works for all three frameworks and is stable across regenerations.
HttpError lives in _shared/errors.ts (see next section). router.ts re-exports it with export { HttpError } from './_shared/errors.js' so the import path above stays clean and unchanged even in multi-spec configurations.
Framework error-shape comparison
Each framework has a different error shape for built-in validation failures. Use this table as a reference when writing app-level error handlers:
| Source | Framework | Status | Body shape |
|---|---|---|---|
| HttpError from service | All | any (your .status) | { error: err.message } (Hono/Express) or { statusCode, code, error, message } (Fastify) |
| Zod validation failure | Hono, Express | 422 | { error: 'Invalid request body', issues: [...] } |
| Zod validation failure | Fastify | 400 | { statusCode: 400, code: 'FST_ERR_VALIDATION', error: '...', message: '...' } |
| Unsupported Content-Type | Hono | 415 | { error: 'Unsupported Media Type' } |
| Unsupported Content-Type | Fastify | 415 | { statusCode: 415, code: 'FST_ERR_CTP_INVALID_MEDIA_TYPE', error: '...', message: '...' } |
How error propagation works
The generated router maps service-call errors in two cases. Hono and Express use a per-route try/catch; Fastify registers a single setErrorHandler once (no per-route try/catch). Both handle the same two cases:
HttpError: mapped to its.statuscode. Usenew HttpError(404, 'Pet not found')inside service methods to return structured HTTP error responses.- All other errors: re-thrown to your app-level error handler (
setErrorHandlerin Fastify, error-handling middleware in Express,app.onErrorin Hono).
This means custom error types that do NOT extend HttpError propagate to the framework layer, where you install a single error handler for logging, monitoring, and response shaping.
Example: custom error reaching Fastify's setErrorHandler
// Your custom error class: does NOT extend HttpError
class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`)
this.name = 'NotFoundError'
}
}
// Service implementation throws NotFoundError
export const petService: PetstoreService = {
async getPet(id) {
const pet = db.get(id)
if (!pet) throw new NotFoundError(`Pet ${id}`)
return pet
},
// ...
}
// Register a Fastify error handler ONCE at the app level.
// The generated router re-throws non-HttpError errors, so they arrive here.
fastify.setErrorHandler((err, request, reply) => {
if (err.name === 'NotFoundError') {
return reply.status(404).send({ error: err.message })
}
// Unknown errors become 500
fastify.log.error(err)
return reply.status(500).send({ error: 'Internal server error' })
})
fastify.register(createRouter(petService), { prefix: '/api' })The same pattern applies to Express error middleware (app.use((err, req, res, next) => { ... })) and to Hono's app.onError((err, c) => { ... }).
Shared runtime (_shared/) and multi-spec mounting
Every generation run writes one _shared/errors.ts file that all generated routers import from. This design ensures that err instanceof HttpError works correctly when multiple generated routers are mounted in the same server process, a check that would silently fail if each router defined its own copy of the class.
Where _shared/ is written
The generator derives the shared location automatically:
Single project (one
outputpath):_shared/is placed inside the output directory itself.output: "generated"→ shared file atgenerated/_shared/errors.ts, imported as./_shared/errors.jsinrouter.ts.Multiple projects (via
projectsarray in config):_shared/is placed at the longest common parent directory of alloutputpaths.output: "gen/public"andoutput: "gen/admin"→ shared file atgen/_shared/errors.ts, imported as../_shared/errors.jsin eachrouter.ts.Override (
shared_outputconfig field): set"shared_output": "path/to/shared"to place_shared/errors.tsat a specific location instead of the derived one. Useful when the output dirs share no common parent.
Multi-spec mounting example
A real-world pattern: two API specs (public and admin) generate into sibling directories and are mounted in the same Fastify server. Because both routers import HttpError from the same gen/_shared/errors.ts, a single app-level error handler covers both routers and instanceof checks work correctly.
Config (openapi-server.config.json):
{
"projects": [
{
"input_openapi": "./specs/public.json",
"output": "./gen/public",
"framework": "fastify"
},
{
"input_openapi": "./specs/admin.json",
"output": "./gen/admin",
"framework": "fastify"
}
]
}This writes:
gen/public/router.ts: importsHttpErrorfrom../_shared/errors.jsgen/admin/router.ts: importsHttpErrorfrom../_shared/errors.jsgen/_shared/errors.ts: the single sharedHttpErrorclass
Mounting both routers:
import Fastify from 'fastify'
import { HttpError } from './gen/_shared/errors.js'
import { createRouter as createPublicRouter } from './gen/public/router.js'
import { createRouter as createAdminRouter } from './gen/admin/router.js'
import { publicService } from './src/publicService.js'
import { adminService } from './src/adminService.js'
const fastify = Fastify()
// Both routers use the same HttpError class: instanceof works correctly.
fastify.register(createPublicRouter(publicService), { prefix: '/api/v1' })
fastify.register(createAdminRouter(adminService), { prefix: '/admin' })
// One app-level error handler covers both routers.
fastify.setErrorHandler((err, request, reply) => {
if (err instanceof HttpError) {
return reply.status(err.status).send({ error: err.message })
}
fastify.log.error(err)
return reply.status(500).send({ error: 'Internal server error' })
})
fastify.listen({ port: 3000 })Because HttpError is a single class imported from one shared file, the instanceof check on line if (err instanceof HttpError) works for errors thrown in either router. Without the shared module, each router's locally-defined class would be a distinct identity and the check would silently fail for errors from the other router.
Custom routes (registerCustomRoutes, Fastify only)
The Fastify createRouter accepts an optional registerCustomRoutes callback that runs after the ZodTypeProvider compilers, error handler, and body parsers are set up, but before the spec-generated routes. Custom routes registered here inherit the ZodTypeProvider context and the HttpError error handler.
import { createRouter, HttpError } from './generated/router.js'
fastify.register(
createRouter(petService, {
registerCustomRoutes: async (app) => {
app.get('/health', async (_req, reply) => {
return reply.send({ status: 'ok' })
})
app.get('/metrics', async (_req, reply) => {
const count = await db.countPets()
return reply.send({ pets: count })
})
},
}),
{ prefix: '/api' }
)Custom routes can throw HttpError and it will be caught by the same error handler as the spec-generated routes:
registerCustomRoutes: async (app) => {
app.post('/admin/reset', async (req, reply) => {
const auth = req.headers.authorization
if (!auth) throw new HttpError(401, 'Unauthorized')
await db.reset()
return reply.status(204).send()
})
}Sibling-plugin pattern for non-spec routes:
For routes that do not belong in the spec (health checks, metrics, webhook receivers), register them as a separate Fastify plugin alongside the generated router rather than embedding them in registerCustomRoutes:
import Fastify from 'fastify'
import { createRouter } from './generated/router.js'
const fastify = Fastify()
// Non-spec routes as a standalone plugin
fastify.register(async (app) => {
app.get('/health', async (_req, reply) => reply.send({ status: 'ok' }))
app.get('/readyz', async (_req, reply) => reply.send({ ready: true }))
}, { prefix: '/' })
// Generated router with its own ZodTypeProvider scope
fastify.register(createRouter(petService), { prefix: '/api' })
// App-level error handler
fastify.setErrorHandler((err, request, reply) => {
fastify.log.error(err)
return reply.status(500).send({ error: 'Internal server error' })
})
fastify.listen({ port: 3000 })The sibling-plugin pattern is preferred when the custom routes do not need ZodTypeProvider or HttpError handling. Use registerCustomRoutes when they do.
Global lifecycle hooks (Fastify only)
The Fastify createRouter accepts four optional hook options in CreateRouterOptions. Each accepts a single handler or an array of handlers:
| Option | Fastify lifecycle point | Typical use |
|---|---|---|
| onRequest | Very first hook, before validation | Authentication, rate-limiting, request ID injection |
| preHandler | After validation, before handler | Authorization, context enrichment |
| onSend | After handler, before response is sent | Response header injection, logging |
| onError | When a handler or hook throws | Observability, error metrics |
Hooks are plugin-scoped: they apply to all spec-generated routes and to any routes added via registerCustomRoutes, but they do NOT propagate to the parent Fastify instance (standard Fastify encapsulation).
Hook execution order per request: onRequest -> preHandler -> route handler -> onSend.
import { createRouter } from './generated/router.js'
import type { onRequestHookHandler } from 'fastify'
// A reusable onRequest hook that validates a Bearer token.
const authHook: onRequestHookHandler = async (req, reply) => {
const token = req.headers.authorization
if (!token?.startsWith('Bearer ')) {
return reply.status(401).send({ error: 'Unauthorized' })
}
}
fastify.register(
createRouter(petService, {
onRequest: authHook,
// Arrays are also accepted when you need multiple hooks.
onError: [metricsHook, loggingHook],
}),
{ prefix: '/api' }
)onError vs errorHandler: onError hooks fire for observability (logging, metrics) but do NOT produce the response. The errorHandler option (or the built-in HttpError handler) is the single response-producer. Both can coexist safely: the error handler writes the response, then any onError hooks run.
Request-scoped context / caller principal (context_type)
The context_type config option threads a typed caller context through every generated service method. Use it to pass an authentication principal, a tenant ID, or any per-request metadata without coupling service code to framework types.
Config:
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"context_type": "RequestContext"
}What changes in generated service.ts:
// Without context_type (default, Hono/Express style):
export interface PetstoreService {
listPets(params?: { species?: string }): Promise<Pet[]>
getPet(id: string): Promise<Pet>
}
// With context_type: "RequestContext" (Hono/Express):
export interface PetstoreService<Ctx = never> {
listPets(params?: { species?: string }, ctx: Ctx): Promise<Pet[]>
getPet(id: string, ctx: Ctx): Promise<Pet>
}
// Fastify (framework: "fastify") with context_type: "RequestContext":
// ctx is a separate trailing arg after the required input object.
// Zero-facet ops receive only ctx (no input param).
export interface PetstoreService<Ctx = never> {
listPets(input: { query: { species?: string } }, ctx: Ctx): Promise<Pet[]>
getPet(input: { params: { id: string } }, ctx: Ctx): Promise<Pet>
getHealth(ctx: Ctx): Promise<void> // zero-facet: no input param
}The generic default Ctx = never keeps the interface usable when no context is needed: existing implementations that do not pass ctx continue to compile as long as the service is instantiated without a type argument.
What changes in generated router.ts:
For Hono and Express the generated router passes the framework's native request/context object as the final argument to every service call. For Fastify the router passes the value returned by a required createContext(req) hook (see the Fastify section below):
| Framework | ctx value passed |
|---|---|
| Hono | c (the Hono Context object) |
| Express | req (the Express Request object) |
| Fastify | the value returned by your createContext(req) hook, not the raw request |
// Generated Hono handler (with context_type: "RequestContext"):
app.get('/pets', async (c) => {
try {
return c.json(await service.listPets(c))
} catch (err) { ... }
})Fastify: the createContext auth seam
The canonical full-stack reference app, petstore-fastify, is a live demonstration of this seam: Fastify with createContext auth, a cross-field validation rule, and a React/react-query frontend that round-trips a cross-field error onto a form field.
For Fastify, context_type is a runtime auth boundary, not just a type. When context_type is set, createRouter is generic over Ctx and requires an options object with a createContext hook. The concrete principal type is inferred at the call site from your service implementation and createContext, so no context type name is baked into the generated file:
createRouter<Ctx = never>(service: PetstoreService<Ctx>, options: CreateRouterOptions<Ctx>)createContext(req) runs first inside every route handler, so it is the place to authenticate the request and build the typed principal. Throw HttpError(401) to reject before any handler work runs. Its return value is passed as ctx to every service method.
import Fastify from 'fastify'
import { createRouter, HttpError, type CreateRouterOptions } from '../generated/router.js'
import type { PetstoreService } from '../generated/service.js'
interface RequestContext {
userId: string
scopes: string[]
}
const petService: PetstoreService<RequestContext> = {
async listPets(input, ctx) {
return db.listPets({ userId: ctx.userId, species: input.query.species })
},
// ... other operations receive ctx as their final argument after the input object
}
const app = Fastify()
app.register(
createRouter(petService, {
// Validate the token or session and build the typed principal. Throw to reject.
createContext: (req) => {
const userId = req.headers['x-user-id']
if (typeof userId !== 'string') throw new HttpError(401, 'Unauthorized')
return { userId, scopes: [] }
},
}),
{ prefix: '/v1' }
)Operation-level security from the spec (falling back to the global security) is surfaced on each route at req.routeOptions.config.security as Array<{ scheme, scopes }>, so createContext (or a preHandler hook) can enforce per-operation scope requirements. It is also emitted as a @security JSDoc tag on each service method. Security is exposed as metadata only: createContext is where you enforce it.
Implementing the service with context (Hono):
import type { PetstoreService } from '../generated/service.js'
import type { Context } from 'hono'
// Define your request context type
interface RequestContext {
userId: string
tenantId: string
}
// Extract context from the Hono Context object in a middleware
const app = new Hono()
app.use('*', async (c, next) => {
const userId = c.req.header('x-user-id') ?? ''
// Store on the Hono context so the generated router can pass it
c.set('userId', userId)
await next()
})
// Implement the service. ctx is whatever the router passed (here: the Hono Context)
export const petService: PetstoreService<Context> = {
async listPets(params, ctx) {
const userId = ctx.get('userId')
return db.listPets({ userId, species: params?.species })
},
async getPet(id, ctx) {
const userId = ctx.get('userId')
return db.getPet(id, userId)
},
}
// Note: for Fastify (framework: "fastify"), service methods receive the single input
// object first, then ctx: listPets(input, ctx) where input.query.species is the field.Backward compatibility: if context_type is not set in the config, the generated output is identical to previous versions. No changes to the interface shape, no extra arguments in service calls.
Non-JSON request bodies (Fastify)
Fastify 5 natively parses only application/json and text/plain request bodies. The generated router automatically registers the required plugins for any content types declared in your spec.
application/x-www-form-urlencoded
When your spec has form-urlencoded request bodies, the generated router auto-registers @fastify/formbody inside the plugin closure. You only need to install it:
pnpm add @fastify/formbodyNo manual registration needed. fastify.register(createRouter(service)) handles it.
multipart/form-data
When your spec has multipart request bodies, the generated router auto-registers @fastify/multipart with attachFieldsToBody: 'keyValues' inside the plugin closure:
pnpm add @fastify/multipartNo manual registration needed. The attachFieldsToBody: 'keyValues' option is set automatically so each field's value lands directly on req.body (file fields arrive as @fastify/multipart file objects), which is what handlers and body validators expect.
Upload size limits: pass multipart.limits in CreateRouterOptions to raise the per-file cap without opting out of auto-registration. The limits are merged over the generated defaults (attachFieldsToBody stays 'keyValues'):
fastify.register(
createRouter(service, {
multipart: { limits: { fileSize: 10_000_000 } },
}),
{ prefix: '/api' }
)Note: @fastify/multipart's fileSize caps the multipart stream, but Fastify's core bodyLimit (default 1 MiB) caps the raw request body first. For large uploads also raise it at instance creation: Fastify({ bodyLimit: 20_000_000 }).
The multipart option is only present in CreateRouterOptions when the spec has multipart bodies. Specs with no multipart bodies get an unchanged CreateRouterOptions.
Full control (all @fastify/multipart options): pass registerParsers: false and register the plugin yourself before mounting the router:
fastify.register(fastifyMultipart, { attachFieldsToBody: 'keyValues', limits: { fileSize: 10_000_000 } })
fastify.register(createRouter(service, { registerParsers: false }), { prefix: '/api' })application/octet-stream
No extra plugin is needed. When your spec declares an application/octet-stream request body, the generator automatically emits an addContentTypeParser call inside createRouter:
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (req, body, done) => done(null, body))addContentTypeParser is a core Fastify API with no additional dependencies. The parsed body is a Buffer and is forwarded directly to the service method.
415 error-shape divergence
When a request arrives with an unsupported content type and no parser is registered, the two frameworks return different shapes:
| Framework | Status | Body shape |
|---|---|---|
| Hono | 415 | { error: 'Unsupported Media Type' } |
| Fastify | 415 | { statusCode: 415, code: 'FST_ERR_CTP_INVALID_MEDIA_TYPE', error: 'Unsupported Media Type', message: '...' } |
Hono uses the shared { error } envelope from the generated router. Fastify uses its own framework-level 415 envelope, which is emitted before the route handler runs. If you rely on a consistent error shape across frameworks, register the appropriate parser or add a Fastify setErrorHandler that normalises the response.
Response validation opt-in (emit_response_validation, Fastify only)
Set emit_response_validation: true in your config to instruct the Fastify emitter to synthesize inline Zod expressions for schema.response on routes that have no named schema from input_schema.
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "fastify",
"emit_response_validation": true
}What the synthesizer does:
- Flat
objectschema with scalar properties: emitsz.object({ field: z.string(), count: z.number() }) - Scalar top-level responses: emits
z.string(),z.number(),z.boolean() - Array of scalars: emits
z.array(z.string())(or the appropriate item type) $ref,allOf,oneOf,anyOf, or nested object properties: emitsz.unknown()for that field or the whole response
Limitations. The synthesizer is intentionally minimal and honest. It does not chase $ref pointers or handle composition keywords. Complex schemas fall back to z.unknown(), which passes through any value. If your spec uses $ref or composition heavily, wire input_schema instead for full coverage.
Recommendation. Use input_schema (point it at the schemas.ts from openapi-zod-ts) for complete, type-safe response validation. emit_response_validation is a lightweight alternative for specs with simple flat inline response schemas where adding a full Zod schema file is not yet practical.
Ecosystem
@codewithagents/openapi-server is one of five published packages, all driven from the same OpenAPI 3.x spec. Four are generators that emit files; api-errors is a runtime helper used in app code:
| Package | What it does |
|---|---|
| openapi-zod-ts | Core generator: TypeScript models + native fetch client + Zod schemas. Required by openapi-server. |
| @codewithagents/openapi-server | Framework-agnostic service interface + optional hono / express / fastify / none (default none) router |
| @codewithagents/openapi-react-query | Typed React Query v5 hooks (useQuery, useMutation, key factories) |
| @codewithagents/openapi-msw | MSW v2 HTTP handlers with seeded Faker mock data |
| @codewithagents/api-errors | Maps backend API error responses to form-field errors (runtime helper, not a codegen step) |
See the petstore-fastify demo for the canonical full-stack example: Fastify with createContext auth, a cross-field validation rule, and a React/react-query frontend, with these packages working together.
