iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔥

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

  1. Define Zod schemas in the backend
  2. Implement API with Hono
  3. Generate OpenAPI schema
  4. Generate types, Tanstack Query custom hooks, etc., using Orval in the frontend
  5. 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.

https://github.com/orval-labs/orval/blob/master/samples/hono/hono-with-fetch-client/petstore.yaml

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

apps/api/src/index.ts
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.

https://github.com/orval-labs/orval/releases/tag/v6.9.0

apps/api/src/index.ts
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:

apps/api/scripts/generate-openapi.ts
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');
package.json
{
  "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.

apps/front/orval.config.ts
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.

apps/front/src/main.tsx
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.

apps/front/src/App.tsx
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!

GitHubで編集を提案

Discussion