iTranslated by AI
Development workflow starting from Zod schemas using @hono/zod-openapi
I tried the following development flow starting from Zod schemas using @hono/zod-openapi.
(I initially described it as a schema-driven development approach, but since I'm actually generating the OpenAPI schema after the API implementation, it wasn't quite accurate, so I corrected the title.)
Project Overview
It uses a monorepo structure utilizing pnpm workspaces.
.
├── apps
│ ├── api
│ │ ├── drizzle
│ │ │ └── migrations
│ │ ├── drizzle.config.ts
│ │ ├── package.json
│ │ ├── scripts
│ │ │ └── generate-openapi.ts
│ │ ├── src
│ │ │ ├── db
│ │ │ │ └── schemas
│ │ │ │ └── pets.ts
│ │ │ └── index.ts
│ │ └── wrangler.jsonc
│ ├── front
│ │ ├── index.html
│ │ ├── orval.config.ts
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── custom-instance.ts
│ │ │ ├── App.css
│ │ │ ├── App.tsx
│ │ │ ├── generated
│ │ │ │ ├── models
│ │ │ │ ├── pets
│ │ │ │ ├── swaggerPetstore.msw.ts
│ │ │ │ └── swaggerPetstore.ts
│ │ │ ├── index.css
│ │ │ └── main.tsx
│ │ └── vite.config.ts
│ └── openapi.yaml
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
Development Flow
- Define Zod schemas in the backend
- Implement API with Hono
- Generate OpenAPI schema
- Generate types, Tanstack Query custom hooks, etc., using Orval in the frontend
- Implement using the types and Tanstack Query custom hooks generated in step 4
API Implementation
Zod Schema Definition
Defining Zod schemas with reference to the petstore.yaml OpenAPI schema found in Orval's samples.
import { z } from '@hono/zod-openapi';
const PetSchema = z
.object({
id: z.number().int().openapi({ format: 'int64' }),
name: z
.string()
.min(1, 'Name is required')
.max(40)
.openapi({ description: 'Name of the pet' }),
tag: z
.string()
.min(1, 'Tag is required')
.max(20)
.openapi({ description: 'Type of the pet' }),
})
.openapi('Pet');
const PetsSchema = z.array(PetSchema).openapi('Pets');
const ErrorSchema = z
.object({
code: z.number().int().openapi({ example: 500, format: 'int32' }),
message: z.string().openapi({ example: 'Internal Server Error' }),
})
.openapi('Error');
Zod methods are converted into types and constraints for the properties in the OpenAPI schema.
For example, min() and max() are output as minimum and maximum through OpenAPI schema generation.
If you want to represent exclusiveMinimum or exclusiveMaximum, use gt() or lt().
API Implementation with Hono
import type { D1Database } from '@cloudflare/workers-types';
import { z, createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { swaggerUI } from '@hono/swagger-ui';
import { drizzle } from 'drizzle-orm/d1';
import { eq } from 'drizzle-orm';
import { pets } from './db/schemas/pets';
import { cors } from 'hono/cors';
type Bindings = {
DB: D1Database;
};
// Zod schema definitions omitted
const app = new OpenAPIHono<{ Bindings: Bindings }>().basePath('/api');
app.use('/api/*', cors())
// GET /pets - List all pets
app.openapi(
createRoute({
method: 'get',
path: '/pets',
summary: 'List all pets',
operationId: 'listPets',
tags: ['pets'],
request: {
query: z.object({
limit: z.string().optional().openapi({
description: 'How many items to return at one time (max 100)',
}),
}),
},
responses: {
200: {
description: 'A paged array of pets',
content: {
'application/json': {
schema: PetsSchema,
},
},
},
},
}),
async (c) => {
const db = drizzle(c.env.DB);
const { limit } = c.req.valid('query');
const limitNum = limit ? parseInt(limit) : undefined;
const allPets = await db
.select()
.from(pets)
.limit(limitNum || 100)
.all();
return c.json(allPets, 200);
}
);
// POST /pets - Create a pet
app.openapi(
createRoute({
method: 'post',
path: '/pets',
summary: 'Create a pet',
operationId: 'createPet',
tags: ['pets'],
request: {
body: {
required: true,
content: {
'application/json': {
schema: PetSchema.omit({ id: true }),
},
},
},
},
responses: {
200: {
description: 'Created Pet',
content: {
'application/json': {
schema: PetSchema,
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: ErrorSchema,
},
},
},
},
}),
async (c) => {
const db = drizzle(c.env.DB);
const body = c.req.valid('json');
const result = await db
.insert(pets)
.values({
name: body.name,
tag: body.tag,
})
.returning()
.get();
return c.json(result, 200);
}
);
// PUT /pets - Update a pet
app.openapi(
createRoute({
method: 'put',
path: '/pets',
summary: 'Update a pet',
operationId: 'updatePets',
tags: ['pets'],
request: {
body: {
required: true,
content: {
'application/json': {
schema: PetSchema,
},
},
},
},
responses: {
200: {
description: 'Created Pet',
content: {
'application/json': {
schema: PetSchema,
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: ErrorSchema,
},
},
},
},
}),
async (c) => {
const db = drizzle(c.env.DB);
const updatedPet = c.req.valid('json');
const result = await db
.update(pets)
.set({
name: updatedPet.name,
tag: updatedPet.tag,
})
.where(eq(pets.id, updatedPet.id))
.returning()
.get();
if (!result) {
return c.json({ code: 404, message: 'Pet not found' }, 404);
}
return c.json(result, 200);
}
);
// GET /pets/{petId} - Info for a specific pet
app.openapi(
createRoute({
method: 'get',
path: '/pets/{petId}',
summary: 'Info for a specific pet',
operationId: 'showPetById',
tags: ['pets'],
request: {
params: z.object({
petId: z.string().openapi({
description: 'The id of the pet to retrieve',
}),
}),
},
responses: {
200: {
description: 'Expected response to a valid request',
content: {
'application/json': {
schema: PetSchema,
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: ErrorSchema,
},
},
},
},
}),
async (c) => {
const db = drizzle(c.env.DB);
const { petId } = c.req.valid('param');
const pet = await db
.select()
.from(pets)
.where(eq(pets.id, parseInt(petId)))
.get();
if (!pet) {
return c.json({ code: 404, message: 'Pet not found' }, 404);
}
return c.json(pet, 200);
}
);
export { app };
export default app;
You can check the OpenAPI specification through a web interface using Swagger UI.
It seems that support for OpenAPI v3.1 was added in Orval v6.9.0, but since I couldn't clarify it from the documentation, I'm using the v3 series for now.
If you want to use v3.1, define it using doc31 or getOpenAPI31Document.
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Swagger Petstore',
license: {
name: 'MIT',
},
},
});
app.get('/doc/ui', swaggerUI({ url: '/api/doc' }));

OpenAPI Schema Generation
Script for generating the schema file:
import { writeFileSync } from 'node:fs';
import { app } from '../src/index.js';
import yaml from 'yaml';
const doc = app.getOpenAPIDocument(
{
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'Swagger Petstore',
license: { name: 'MIT' },
},
},
{
unionPreferredType: 'oneOf',
}
);
// YAML output
writeFileSync('../openapi.yaml', yaml.stringify(doc));
console.log('✅ openapi.yaml generated');
// JSON output
// writeFileSync('../openapi.json', JSON.stringify(doc, null, 2));
// console.log('✅ openapi.json generated');
{
"scripts": {
"openapi:generate": "tsx scripts/generate-openapi.ts",
}
}
pnpm api openapi:generate
> schema-driven-development-otameshi@1.0.0 api /Users/kmkkiii/ghq/github.com/kmkkiii/schema-driven-development-otameshi
> pnpm -F api openapi:generate
> api@ openapi:generate /Users/kmkkiii/ghq/github.com/kmkkiii/schema-driven-development-otameshi/apps/api
> tsx scripts/generate-openapi.ts
✅ openapi.yaml generated
Generating Types, Tanstack Query Custom Hooks, etc., from OpenAPI Schema with Orval
Configure the type of API client to generate and the storage location for the generated files.
import { defineConfig } from 'orval';
export default defineConfig({
petstoreClient: {
input: {
target: '../openapi.yaml',
},
output: {
mode: 'tags-split',
client: 'react-query',
target: 'src/generated/',
schemas: 'src/generated/models',
mock: true,
httpClient: 'axios',
override: {
mutator: {
path: './src/api/custom-instance.ts',
name: 'customInstance',
},
},
},
},
});
pnpm front orval
> schema-driven-development-otameshi@1.0.0 front /Users/kmkkiii/ghq/github.com/kmkkiii/schema-driven-development-otameshi
> pnpm -F front orval
> front@0.0.0 orval /Users/kmkkiii/ghq/github.com/kmkkiii/schema-driven-development-otameshi/apps/front
> orval
🍻 orval v7.17.0 - A swagger client generator for typescript
🎉 petstoreClient - Your OpenAPI spec has been converted into ready to use orval!
The following files are generated:
.
├── apps
│ ├── front
│ │ ├── src
│ │ │ ├── generated
│ │ │ │ ├── models
│ │ │ │ │ ├── createPetBody.ts
│ │ │ │ │ ├── createPetsBodyItem.ts
│ │ │ │ │ ├── error.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── listPetsParams.ts
│ │ │ │ │ ├── pet.ts
│ │ │ │ │ └── pets.ts
│ │ │ │ ├── pets
│ │ │ │ │ ├── pets.msw.ts
│ │ │ │ │ └── pets.ts
│ │ │ │ ├── swaggerPetstore.msw.ts
│ │ │ │ └── swaggerPetstore.ts
Frontend Implementation Using the Generated Tanstack Query Custom Hooks
Preparing to use Tanstack Query.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);
Implementing using the custom hooks.
import './App.css';
import { useListPets } from './generated/pets/pets';
function App() {
const { data: pets, isLoading, error } = useListPets();
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<>
<h2>Pets</h2>
<ul>
{pets?.map((pet) => (
<li key={pet.id}>{pet.name}</li>
))}
</ul>
</>
);
}
export default App;
Conclusion
By using Zod, I was able to define validations along with the schema. Additionally, generating types and Tanstack Query custom hooks with Orval allowed for shared implementation, providing a very comfortable development experience.
Lately, I've also been interested in TypeSpec and oRPC, so I'd like to try them out as well.
Thank you for reading this far!
Discussion