Types and fetch client
@codewithagents/openapi-gen generates the models.ts that service.ts imports from. Run both generators together against the same spec.
@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 Hono, Express, Fastify, or any router you already use. Set "framework": "hono" (or "express" / "fastify") 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.
Running the generator produces up to two files in your output directory:
| File | Contents |
|---|---|
service.ts | TypeScript interface with one typed async method per API operation. No framework imports. |
router.ts | Router factory, generated only when framework is set to "hono", "express", or "fastify" |
Key guarantees:
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."hono", "express", or "fastify" to get a ready-to-mount router as a starting point. Use "none" to wire the interface yourself with any framework.input_schema at the same Zod schema file you use with @codewithagents/openapi-gen. The generator adds safeParse calls to every route that receives a body, returning a structured 422 before the call ever reaches your service.prettier --check with your project’s own Prettier config.strict: true. All output compiles cleanly with TypeScript strict mode.$ref, allOf, anyOf, oneOf, and nullable.npm i -D @codewithagents/openapi-serverpnpm add -D @codewithagents/openapi-serveryarn add -D @codewithagents/openapi-server1. Create openapi-server.config.json in your project root:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono"}2. Run both generators:
npx openapi-gen && npx openapi-serverOr add a combined script to package.json:
{ "scripts": { "generate": "openapi-gen && openapi-server" }}3. Implement the generated service interface:
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:
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 /apiconst 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.
npx openapi-server --config ./config/openapi-server.config.json| Field | Type | Required | Default | Description |
|---|---|---|---|---|
input_openapi | string | Yes | n/a | Path to your OpenAPI spec (JSON or YAML) |
output | string | Yes | n/a | Directory to write the generated files |
framework | "hono" | "express" | "fastify" | "none" | No | "none" | Router framework to generate. Use "none" to generate only service.ts |
input_schema | string | No | n/a | Path to user-owned Zod schema file. Enables server-side request validation (never overwritten) |
Full config example:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono", "input_schema": "./generated/schemas.ts"}service.tsGiven 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.
router.tscreateRouter(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}createRouter(service) returns an Express Router. Apply express.json() before mounting so req.body is populated:
// generated/router.ts (auto-generated, framework: "express")
import { Router } from 'express'import type { Request, Response } from 'express'import type { CreatePetRequest } from './models.js'import type { PetstoreService } from './service.js'
export function createRouter(service: PetstoreService): Router { const router = Router()
router.get('/pets', async (req: Request, res: Response) => { const params = { species: req.query['species'] as string | undefined, limit: Number(req.query['limit'] as string), } res.json(await service.listPets(params)) })
router.post('/pets', async (req: Request, res: Response) => { const body = req.body as CreatePetRequest res.status(201).json(await service.createPet(body)) })
router.get('/pets/:id', async (req: Request, res: Response) => { res.json(await service.getPet(req.params['id']!)) })
router.delete('/pets/:id', async (req: Request, res: Response) => { await service.deletePet(req.params['id']!) res.status(204).end() })
return router}createRouter(app, service) registers routes directly onto a FastifyInstance and returns void. Use fastify.register to apply a path prefix:
// generated/router.ts (auto-generated, framework: "fastify")
import type { FastifyInstance } from 'fastify'import type { CreatePetRequest } from './models.js'import type { PetstoreService } from './service.js'
export function createRouter(app: FastifyInstance, service: PetstoreService): void { app.get<{ Querystring: { species?: string; limit?: number } }>('/pets', async (req, reply) => { const params = { species: req.query.species, limit: req.query.limit, } return service.listPets(params) })
app.post<{ Body: CreatePetRequest }>('/pets', async (req, reply) => { reply.status(201) return service.createPet(req.body) })
app.get<{ Params: { id: string } }>('/pets/:id', async (req, reply) => { return service.getPet(req.params.id) })
app.delete<{ Params: { id: string } }>('/pets/:id', async (req, reply) => { await service.deletePet(req.params.id) reply.status(204).send() })}The router handles:
{id} becomes :id, extracted via the framework’s native param accessorstring, number, boolean)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:
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:
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)Set "framework": "fastify" and the generator produces a createRouter that registers routes directly onto a FastifyInstance (it returns void, not a new instance). Use fastify.register with a prefix:
import Fastify from 'fastify'import { createRouter } from '../generated/router.js'import { petService } from './petService.js'
const fastify = Fastify()
fastify.register( async (instance) => { createRouter(instance, petService) }, { prefix: '/api' })
fastify.listen({ port: 3001 })"framework": "none"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.input_schema)Point input_schema at the same schemas.ts you use with @codewithagents/openapi-gen. 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.
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 safeParse calls for every route whose body type has a matching schema in input_schema. 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 routesapp.use('/api/*', cors())app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! }))
app.route('/api', createRouter(petService))import express from 'express'import { createRouter } from '../generated/router.js'import { petService } from './petService.js'
const app = express()
app.use(express.json())
// Auth middleware runs before the generated routerapp.use('/api', (req, res, next) => { if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) { return res.status(401).json({ error: 'Unauthorized' }) } next()})
app.use('/api', createRouter(petService))import Fastify from 'fastify'import { createRouter } from '../generated/router.js'import { petService } from './petService.js'
const app = Fastify()
// Register auth as a scoped plugin before the routesapp.register( async (instance) => { instance.addHook('onRequest', async (req, reply) => { if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) { return reply.status(401).send({ error: 'Unauthorized' }) } }) createRouter(instance, petService) }, { prefix: '/api' })The generated router does not wrap service calls in try/catch. If your service method throws, the error propagates to the framework’s own error handler, which typically returns 500. Add a custom error handler to control the response shape:
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)})import express, { NextFunction, Request, Response } 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))
// Error middleware must have four parametersapp.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error(err) res.status(500).json({ error: err.message })})import Fastify from 'fastify'import { createRouter } from '../generated/router.js'import { petService } from './petService.js'
const app = Fastify()
app.setErrorHandler((err, req, reply) => { console.error(err) reply.status(500).send({ error: err.message })})
app.register( async (instance) => { createRouter(instance, petService) }, { prefix: '/api' })The generator intentionally omits several OpenAPI features. These are not validated or generated:
| Feature | Status |
|---|---|
| Response-body validation | Not generated. Validate in your service if needed. |
| Request headers and cookies | Not extracted or validated. |
| Security scheme enforcement | Not generated. Add auth middleware manually (see above). |
multipart/form-data bodies | Not generated. Wire file upload routes manually. |
| Path parameter validation | Not generated. Path params are passed to your service as raw strings. Validate them in your service implementation. |
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 @codewithagents/openapi-gen. Run openapi-gen before openapi-server. A combined script ("generate": "openapi-gen && openapi-server") prevents this.
My service throws but the client sees 500 with no details.
The generated router does not catch errors. Add a framework error handler as shown in the Error handling section above.
Types and fetch client
@codewithagents/openapi-gen generates the models.ts that service.ts imports from. Run both generators together against the same spec.
React Query hooks
Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks for the same spec your server implements.
Form error mapping
Add @codewithagents/api-errors to map API error responses from the generated client directly to form field errors.