Skip to content

Server interface

@codewithagents/openapi-server reads your OpenAPI 3.x spec and writes a typed service interface into your project. The interface is plain TypeScript with no framework imports: implement it however you want and wire it to Fastify, Hono, Express, or any router you already use. Set "framework" to "fastify", "hono", or "express" (default "none") and the generator also scaffolds a ready-to-mount router, with optional Zod validation at the boundary so invalid requests never reach your service implementation. Fastify is the framework the project ships its canonical full-stack app on; the Hono and Express routers are first-class alternatives.

Running the generator produces up to two files in your output directory:

FileContents
service.tsTypeScript interface with one typed async method per API operation. No framework imports.
router.tsRouter factory, generated only when framework is set to "hono", "express", or "fastify"

Key guarantees:

  • Framework-agnostic service interface. service.ts has zero framework dependencies. The TypeScript compiler enforces the contract: add an endpoint in the spec and forget to implement it, and the build fails.
  • Optional router scaffolding. Choose "fastify", "hono", or "express" to get a ready-to-mount router as a starting point. Use "none" (the default) to wire the interface yourself with any framework.
  • Supports GET, POST, PUT, PATCH, DELETE. All five HTTP methods are generated when present in the spec.
  • Zod validation at the boundary. Point input_schema at the same Zod schema file you use with openapi-zod-ts. Hono and Express get safeParse calls that return a structured 422 before the call ever reaches your service. Fastify wires the same schemas through fastify-type-provider-zod and rejects invalid requests with its native 400 (FST_ERR_VALIDATION).
  • Prettier-clean output. Every generated file passes prettier --check with your project’s own Prettier config.
  • strict: true. All output compiles cleanly with TypeScript strict mode.
  • OpenAPI 3.1.x is the primary target (including 3.1.1). OpenAPI 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, and nullable.
Terminal window
npm i -D @codewithagents/openapi-server

1. Create openapi-server.config.json in your project root:

{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono"
}

2. Run both generators:

Terminal window
npx openapi-zod-ts && npx openapi-server

Or add a combined script to package.json:

{
"scripts": {
"generate": "openapi-zod-ts && openapi-server"
}
}

3. Implement the generated service interface:

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

4. Mount the generated router and serve:

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

The config file must be valid JSON and must have a .json extension. By default the generator looks for openapi-server.config.json in the current working directory. Use --config <path> to point at a different file.

Terminal window
npx openapi-server --config ./config/openapi-server.config.json
FieldTypeRequiredDefaultDescription
input_openapistringYesn/aPath to your OpenAPI spec (JSON or YAML)
outputstringYesn/aDirectory to write the generated files
framework"hono" | "express" | "fastify" | "none"No"none"Router framework to generate. Use "none" to generate only service.ts
input_schemastringNon/aPath to user-owned Zod schema file. Enables server-side request validation (never overwritten)
context_typestringNon/aTypeScript type name threaded through every service method as a final ctx argument. On Fastify this becomes a required createContext(req) auth seam. See request-scoped context.
emit_response_validationbooleanNofalseFastify only. Synthesize inline Zod response schemas for flat inline response shapes. See response validation.
shared_outputstringNoderivedOverride directory for the shared _shared/errors.ts file. Defaults to a location derived from the output paths.

Full config example:

{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts"
}

Given a petstore spec (GET /pets, POST /pets, GET /pets/{id}, DELETE /pets/{id}), the generator produces a plain TypeScript interface with no framework dependencies:

// generated/service.ts (auto-generated)
import type { CreatePetRequest, Pet } from './models.js'
export interface PetstoreService {
/** GET /pets */
listPets(params?: { species?: string; limit?: number }): 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>
}

The interface is regenerated every time the spec changes. If you add an endpoint in the spec and forget to implement it, TypeScript will fail the build.

Method name derivation: method names come from operationId in your spec (sanitized to camelCase). When operationId is absent, the generator falls back to a verb + path heuristic: GET /pets/{id} becomes getPetsById, POST /users becomes createUsers. Any leading /api/v{n}/ prefix (for example /api/v1/) is stripped before this heuristic runs, so /api/v1/pets/{id} and /pets/{id} both produce getPetsById.

createRouter(service) returns a Hono instance. Mount it anywhere in your app:

// generated/router.ts (auto-generated, framework: "hono")
import { Hono } from 'hono'
import type { CreatePetRequest } from './models.js'
import type { PetstoreService } from './service.js'
export function createRouter(service: PetstoreService): Hono {
const app = new Hono()
app.get('/pets', async (c) => {
const params = {
species: c.req.query('species') ?? undefined,
limit: c.req.query('limit') !== undefined ? Number(c.req.query('limit')) : undefined,
}
return c.json(await service.listPets(params))
})
app.post('/pets', async (c) => {
const body = await c.req.json<CreatePetRequest>()
return c.json(await service.createPet(body), 201)
})
app.get('/pets/:id', async (c) => {
return c.json(await service.getPet(c.req.param('id')))
})
app.delete('/pets/:id', async (c) => {
await service.deletePet(c.req.param('id'))
return new Response(null, { status: 204 })
})
return app
}

The router handles:

  • Path params: {id} becomes :id, extracted via the framework’s native param accessor
  • Query params: extracted and typed (string, number, boolean)
  • Request bodies: parsed and cast to the correct model type. On Fastify, req.body, req.query, and req.params are inferred from the attached Zod schemas, so handlers are fully typed with no manual generics
  • Response status: spec-driven. The generator reads your spec’s response codes first (checking for 201, then 204, then 200). When no explicit code is declared, it falls back to 204 for DELETE and 200 for all other methods.

createRouter returns a plain Hono instance. Mount it at any path prefix, add middleware, or nest it inside a larger app:

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()
app.route('/api', createRouter(petService))
serve({ fetch: app.fetch, port: 3001 })

Set "framework": "express" and the generator produces a createRouter that returns an Express Router. Apply express.json() middleware before mounting so req.body is populated:

src/server/index.ts
import express from 'express'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = express()
app.use(express.json())
app.use('/api', createRouter(petService))
app.listen(3001)

Use "none" when you want to own the routing layer entirely. The generator produces only service.ts and adds no runtime dependencies:

{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "none"
}

Implement the interface and wire it to any router manually:

import { createServer } from 'node:http'
import type { PetstoreService } from '../generated/service.js'
import { petService } from './petService.js'
// Wire PetstoreService however you like: vanilla http, Koa, Bun, Deno, or anything else.

Point input_schema at the same schemas.ts you use with openapi-zod-ts. For the Hono and Express routers the generator adds safeParse calls to every route that receives a request body. Invalid requests get a structured 422 response and never reach your service implementation. The Fastify router validates differently: it wires the same Zod schemas through fastify-type-provider-zod and rejects with Fastify’s native 400 (see the Fastify note below).

Config:

{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts"
}

Generated router with validation (two-pass output):

app.post('/pets', async (c) => {
const body = await c.req.json<CreatePetRequest>()
// Validate request body: returns 422 with Zod issues on failure
const parseResult = CreatePetRequestSchema.safeParse(body)
if (!parseResult.success) {
return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422)
}
const validatedBody = parseResult.data
return c.json(await service.createPet(validatedBody), 201)
})

Invalid requests receive a structured 422 response:

{
"error": "Invalid request body",
"issues": [{ "code": "too_small", "path": ["name"], "message": "Name is required" }]
}

The generator runs in two passes: first it writes router.ts without any Zod imports, then it rewrites router.ts with validation for every route whose body type has a matching schema in input_schema. For Hono and Express this means safeParse calls; for Fastify it means schemas threaded through fastify-type-provider-zod. If the schema file does not exist yet at generation time, the second pass is skipped and validation is added on the next run.

The generated router is a plain framework object. Add any middleware before mounting it:

import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'
import { cors } from 'hono/cors'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
// Auth and CORS apply to all /api routes
app.use('/api/*', cors())
app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! }))
app.route('/api', createRouter(petService))

The generated router maps thrown errors in two cases. Throw HttpError (re-exported from router.ts) from a service method to return a structured response at a chosen status; any other error propagates to your app-level handler. Hono and Express implement this with a per-route try/catch; Fastify registers a single HttpError setErrorHandler inside its plugin scope. Add a framework-level error handler to shape responses for the errors that propagate:

import { HttpError } from '../generated/router.js'
// Inside a service method:
if (!pet) throw new HttpError(404, 'Pet not found')
import { Hono } from 'hono'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
app.route('/api', createRouter(petService))
app.onError((err, c) => {
console.error(err)
return c.json({ error: err.message }, 500)
})

Set context_type to thread 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. Each method gains a final ctx argument, and the interface becomes generic with a Ctx = never default so existing implementations keep compiling.

{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "fastify",
"context_type": "RequestContext"
}

For Hono and Express the router passes the framework’s native request or context object as ctx. For Fastify, context_type is a runtime auth seam, not just a type: createRouter becomes generic over Ctx and requires an options object with a createContext(req) hook. The hook runs first inside every 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; the return value is passed as ctx to every service method.

import Fastify from 'fastify'
import { createRouter, HttpError } from '../generated/router.js'
import type { PetstoreService } from '../generated/service.js'
interface RequestContext {
userId: string
scopes: string[]
}
const petService: PetstoreService<RequestContext> = {
async listPets(params, ctx) {
return db.listPets({ userId: ctx.userId, ...params })
},
// ... other operations receive ctx as their final argument
}
const app = Fastify()
app.register(
createRouter(petService, {
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 }>, and as a @security JSDoc tag on each service method. Security is metadata only: createContext (or a preHandler hook) is where you enforce it.

Custom routes and lifecycle hooks (Fastify)

Section titled “Custom routes and lifecycle hooks (Fastify)”

The Fastify createRouter accepts a CreateRouterOptions object with several Fastify-only escape hatches:

  • registerCustomRoutes(app): register non-spec routes (health checks, metrics) that inherit the ZodTypeProvider context and the HttpError handler. Runs after the compilers and parsers are set up, before the spec routes.
  • onRequest, preHandler, onSend, onError: plugin-scoped lifecycle hooks, each a single handler or an array. They cover generated and custom routes but do not propagate to the parent instance. Execution order per request: onRequest then preHandler then handler then onSend.
  • registerParsers: false: skip auto-registering @fastify/formbody and @fastify/multipart so you can register them yourself with custom options.

onError hooks fire for observability only; the registered HttpError handler (or your setErrorHandler) remains the single response producer.

import { createRouter, HttpError } from '../generated/router.js'
fastify.register(
createRouter(petService, {
registerCustomRoutes: async (app) => {
app.get('/health', async (_req, reply) => reply.send({ status: 'ok' }))
},
onRequest: authHook,
onError: [metricsHook, loggingHook],
}),
{ prefix: '/api' }
)

Fastify 5 natively parses only application/json and text/plain. For other content types declared in your spec, the Fastify router registers the required plugins automatically inside its plugin scope:

Content typeWhat the router doesYou install
application/x-www-form-urlencodedAuto-registers @fastify/formbody@fastify/formbody
multipart/form-dataAuto-registers @fastify/multipart with attachFieldsToBody: true@fastify/multipart
application/octet-streamEmits an addContentTypeParser call; the body is forwarded to your service as a Buffernothing (core Fastify)

Pass registerParsers: false to register the multipart plugin yourself when you need custom options such as upload size limits.

Response validation opt-in (emit_response_validation, Fastify only)

Section titled “Response validation opt-in (emit_response_validation, Fastify only)”

Set emit_response_validation: true to have the Fastify emitter synthesize inline Zod expressions for schema.response on routes that have no named schema from input_schema. The synthesizer is intentionally minimal: it handles flat objects with scalar properties, scalar top-level responses, and arrays of scalars, and falls back to z.unknown() for $ref, allOf, oneOf, anyOf, or nested objects. For complete, type-safe response validation, point input_schema at the schemas.ts from openapi-zod-ts instead.

Coverage differs by framework. The Fastify router validates the most through fastify-type-provider-zod; the Hono and Express routers cover bodies via safeParse and leave the rest to you.

FeatureFastifyHono / Express
Request body validationNative via fastify-type-provider-zod. Rejects with 400 FST_ERR_VALIDATION.safeParse when input_schema is set. Rejects with 422 { error, issues }.
Query and path validationNative, against the Zod schemas attached to each route.Not validated. Path params reach your service as raw strings.
Request header and cookie validationHeaders validated natively; cookie params validated via a _ckv safeParse block (422). Requires @fastify/cookie.Not extracted or validated.
Response-body validationAvailable. Point input_schema at your schemas.ts, or set emit_response_validation for simple inline shapes.Not generated. Validate in your service if needed.
Security scheme enforcementSurfaced as metadata: req.routeOptions.config.security and a @security JSDoc tag. Enforce it in createContext or a preHandler.Not generated. Add auth middleware manually (see above).
multipart/form-data bodiesAuto-registers @fastify/multipart with attachFieldsToBody: true so req.body is populated.Wire file upload routes manually.

Requests are not validated; invalid bodies reach my service. Check that input_schema is set in your config and the schema file exists. If the schema file is missing at generation time, the Zod pass is skipped and validation is not added. Also confirm that the schema name matches the convention: for a body type CreatePetRequest, the schema must be exported as CreatePetRequestSchema. A mismatch triggers a warning to stderr.

Cannot find module './models.js' at runtime. service.ts imports from models.ts which is generated by openapi-zod-ts. Run openapi-zod-ts before openapi-server. A combined script ("generate": "openapi-zod-ts && openapi-server") prevents this.

My service throws but the client sees 500 with no details. The generated router maps HttpError to its status, but re-throws every other error to your app-level handler, which typically returns a bare 500. Throw HttpError(status, message) for responses you control, and add a framework error handler as shown in the Error handling section for everything else.

@codewithagents/openapi-server is one of five published packages driven from the same OpenAPI 3.x spec: four generators (openapi-zod-ts, openapi-server, openapi-react-query, openapi-msw) plus api-errors, a runtime helper used in app code.

Types and fetch client

openapi-zod-ts generates the models.ts that service.ts imports from. Run both generators together against the same spec.

Learn more

React Query hooks

Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks for the same spec your server implements.

Learn more

MSW mock handlers

Add @codewithagents/openapi-msw to generate MSW v2 HTTP handlers with seeded Faker mock data, so the frontend can develop against the same spec before the server is live.

Learn more

Form error mapping

Add @codewithagents/api-errors to map API error responses from the generated client directly to form field errors.

Learn more

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.