Zod + OpenAPI連携:型安全なAPIドキュメント自動生成


Zod + OpenAPI統合の価値

ZodとOpenAPIを連携させることで、以下のメリットが得られます:

  • 単一の真実の情報源: スキーマ定義を一箇所に集約
  • 型安全性: TypeScriptの型とランタイムバリデーションが一致
  • 自動ドキュメント生成: OpenAPI仕様から Swagger UI などを自動生成
  • クライアント生成: OpenAPI仕様からクライアントコードを自動生成
  • テストの容易性: スキーマベースのテストが可能

セットアップ

必要なパッケージのインストール

npm install zod @asteasolutions/zod-to-openapi
npm install -D @types/node

Honoとの統合(推奨)

npm install hono @hono/zod-openapi

基本的な使い方

シンプルなスキーマ定義

// schemas/user.ts
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

// ZodにOpenAPI拡張を追加
extendZodWithOpenApi(z);

export const UserSchema = z.object({
  id: z.string().uuid().openapi({
    description: 'ユーザーの一意識別子',
    example: '123e4567-e89b-12d3-a456-426614174000'
  }),
  name: z.string().min(1).max(100).openapi({
    description: 'ユーザー名',
    example: '山田太郎'
  }),
  email: z.string().email().openapi({
    description: 'メールアドレス',
    example: 'yamada@example.com'
  }),
  age: z.number().int().min(0).max(150).optional().openapi({
    description: '年齢',
    example: 25
  }),
  role: z.enum(['admin', 'user', 'guest']).openapi({
    description: 'ユーザーロール',
    example: 'user'
  }),
  createdAt: z.string().datetime().openapi({
    description: '作成日時',
    example: '2024-01-01T00:00:00Z'
  })
}).openapi('User');

export type User = z.infer<typeof UserSchema>;

OpenAPI仕様の生成

// openapi/generator.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import { UserSchema } from '../schemas/user';

// レジストリの作成
const registry = new OpenAPIRegistry();

// スキーマを登録
registry.register('User', UserSchema);

// パスの定義
registry.registerPath({
  method: 'get',
  path: '/users/{id}',
  summary: 'ユーザー情報を取得',
  tags: ['Users'],
  request: {
    params: z.object({
      id: z.string().uuid()
    })
  },
  responses: {
    200: {
      description: 'ユーザー情報',
      content: {
        'application/json': {
          schema: UserSchema
        }
      }
    },
    404: {
      description: 'ユーザーが見つかりません'
    }
  }
});

// OpenAPI仕様を生成
const generator = new OpenApiGeneratorV3(registry.definitions);

export const openApiDocument = generator.generateDocument({
  openapi: '3.0.0',
  info: {
    title: 'User API',
    version: '1.0.0',
    description: 'ユーザー管理API'
  },
  servers: [
    {
      url: 'https://api.example.com',
      description: 'Production'
    },
    {
      url: 'http://localhost:3000',
      description: 'Development'
    }
  ]
});

Honoとの統合

APIルートの定義

// routes/users.ts
import { createRoute, z } from '@hono/zod-openapi';
import { UserSchema } from '../schemas/user';

// エラーレスポンススキーマ
const ErrorSchema = z.object({
  success: z.boolean().openapi({ example: false }),
  error: z.object({
    code: z.string().openapi({ example: 'NOT_FOUND' }),
    message: z.string().openapi({ example: 'User not found' })
  })
}).openapi('Error');

// ユーザー一覧取得
export const listUsersRoute = createRoute({
  method: 'get',
  path: '/users',
  summary: 'ユーザー一覧を取得',
  tags: ['Users'],
  request: {
    query: z.object({
      page: z.string().regex(/^\d+$/).transform(Number).optional().openapi({
        description: 'ページ番号',
        example: '1'
      }),
      limit: z.string().regex(/^\d+$/).transform(Number).optional().openapi({
        description: '1ページあたりの件数',
        example: '10'
      }),
      role: z.enum(['admin', 'user', 'guest']).optional().openapi({
        description: 'ロールでフィルター'
      })
    })
  },
  responses: {
    200: {
      description: 'ユーザー一覧',
      content: {
        'application/json': {
          schema: z.object({
            success: z.boolean().openapi({ example: true }),
            data: z.array(UserSchema),
            pagination: z.object({
              page: z.number().openapi({ example: 1 }),
              limit: z.number().openapi({ example: 10 }),
              total: z.number().openapi({ example: 100 })
            })
          })
        }
      }
    }
  }
});

// ユーザー取得
export const getUserRoute = createRoute({
  method: 'get',
  path: '/users/{id}',
  summary: 'ユーザー情報を取得',
  tags: ['Users'],
  request: {
    params: z.object({
      id: z.string().uuid().openapi({
        description: 'ユーザーID',
        example: '123e4567-e89b-12d3-a456-426614174000'
      })
    })
  },
  responses: {
    200: {
      description: 'ユーザー情報',
      content: {
        'application/json': {
          schema: z.object({
            success: z.boolean().openapi({ example: true }),
            data: UserSchema
          })
        }
      }
    },
    404: {
      description: 'ユーザーが見つかりません',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

// ユーザー作成
export const createUserRoute = createRoute({
  method: 'post',
  path: '/users',
  summary: 'ユーザーを作成',
  tags: ['Users'],
  request: {
    body: {
      content: {
        'application/json': {
          schema: UserSchema.omit({ id: true, createdAt: true })
        }
      }
    }
  },
  responses: {
    201: {
      description: 'ユーザーが作成されました',
      content: {
        'application/json': {
          schema: z.object({
            success: z.boolean().openapi({ example: true }),
            data: UserSchema
          })
        }
      }
    },
    400: {
      description: 'バリデーションエラー',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

// ユーザー更新
export const updateUserRoute = createRoute({
  method: 'put',
  path: '/users/{id}',
  summary: 'ユーザー情報を更新',
  tags: ['Users'],
  request: {
    params: z.object({
      id: z.string().uuid()
    }),
    body: {
      content: {
        'application/json': {
          schema: UserSchema.omit({ id: true, createdAt: true }).partial()
        }
      }
    }
  },
  responses: {
    200: {
      description: 'ユーザー情報が更新されました',
      content: {
        'application/json': {
          schema: z.object({
            success: z.boolean().openapi({ example: true }),
            data: UserSchema
          })
        }
      }
    },
    404: {
      description: 'ユーザーが見つかりません',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

// ユーザー削除
export const deleteUserRoute = createRoute({
  method: 'delete',
  path: '/users/{id}',
  summary: 'ユーザーを削除',
  tags: ['Users'],
  request: {
    params: z.object({
      id: z.string().uuid()
    })
  },
  responses: {
    204: {
      description: 'ユーザーが削除されました'
    },
    404: {
      description: 'ユーザーが見つかりません',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

アプリケーションの実装

// app.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { swaggerUI } from '@hono/swagger-ui';
import {
  listUsersRoute,
  getUserRoute,
  createUserRoute,
  updateUserRoute,
  deleteUserRoute
} from './routes/users';

const app = new OpenAPIHono();

// ユーザールートの実装
app.openapi(listUsersRoute, async (c) => {
  const { page = 1, limit = 10, role } = c.req.valid('query');
  
  // データベースから取得(仮実装)
  const users = await fetchUsers({ page, limit, role });
  
  return c.json({
    success: true,
    data: users,
    pagination: {
      page,
      limit,
      total: 100
    }
  });
});

app.openapi(getUserRoute, async (c) => {
  const { id } = c.req.valid('param');
  
  const user = await fetchUserById(id);
  
  if (!user) {
    return c.json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: 'User not found'
      }
    }, 404);
  }
  
  return c.json({
    success: true,
    data: user
  });
});

app.openapi(createUserRoute, async (c) => {
  const userData = c.req.valid('json');
  
  const user = await createUser(userData);
  
  return c.json({
    success: true,
    data: user
  }, 201);
});

app.openapi(updateUserRoute, async (c) => {
  const { id } = c.req.valid('param');
  const userData = c.req.valid('json');
  
  const user = await updateUser(id, userData);
  
  if (!user) {
    return c.json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: 'User not found'
      }
    }, 404);
  }
  
  return c.json({
    success: true,
    data: user
  });
});

app.openapi(deleteUserRoute, async (c) => {
  const { id } = c.req.valid('param');
  
  const deleted = await deleteUser(id);
  
  if (!deleted) {
    return c.json({
      success: false,
      error: {
        code: 'NOT_FOUND',
        message: 'User not found'
      }
    }, 404);
  }
  
  return c.body(null, 204);
});

// OpenAPI仕様を公開
app.doc('/openapi.json', {
  openapi: '3.0.0',
  info: {
    title: 'User API',
    version: '1.0.0'
  }
});

// Swagger UIを追加
app.get('/docs', swaggerUI({ url: '/openapi.json' }));

export default app;

高度なスキーマ定義

再利用可能なスキーマ

// schemas/common.ts
import { z } from 'zod';

// ページネーションスキーマ
export const PaginationQuerySchema = z.object({
  page: z.string().regex(/^\d+$/).transform(Number).default('1'),
  limit: z.string().regex(/^\d+$/).transform(Number).default('10')
}).openapi('PaginationQuery');

export const PaginationResponseSchema = z.object({
  page: z.number(),
  limit: z.number(),
  total: z.number(),
  totalPages: z.number()
}).openapi('PaginationResponse');

// タイムスタンプスキーマ
export const TimestampsSchema = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
}).openapi('Timestamps');

// IDスキーマ
export const UUIDSchema = z.string().uuid().openapi({
  description: 'UUID v4形式の識別子',
  example: '123e4567-e89b-12d3-a456-426614174000'
});

リレーション付きスキーマ

// schemas/post.ts
import { z } from 'zod';
import { UserSchema } from './user';
import { TimestampsSchema, UUIDSchema } from './common';

export const PostSchema = z.object({
  id: UUIDSchema,
  title: z.string().min(1).max(200).openapi({
    description: '投稿タイトル',
    example: 'TypeScript入門'
  }),
  content: z.string().min(1).openapi({
    description: '投稿内容',
    example: 'TypeScriptは...'
  }),
  authorId: UUIDSchema.openapi({
    description: '投稿者ID'
  }),
  author: UserSchema.optional().openapi({
    description: '投稿者情報(展開時のみ)'
  }),
  tags: z.array(z.string()).openapi({
    description: 'タグ一覧',
    example: ['TypeScript', 'JavaScript']
  }),
  published: z.boolean().openapi({
    description: '公開状態',
    example: true
  })
}).merge(TimestampsSchema).openapi('Post');

export type Post = z.infer<typeof PostSchema>;

ネストされた配列とオブジェクト

// schemas/order.ts
import { z } from 'zod';

const OrderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive(),
  price: z.number().positive(),
  subtotal: z.number().positive()
}).openapi('OrderItem');

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zipCode: z.string().regex(/^\d{3}-\d{4}$/),
  country: z.string().length(2)
}).openapi('Address');

export const OrderSchema = z.object({
  id: z.string().uuid(),
  customerId: z.string().uuid(),
  items: z.array(OrderItemSchema).min(1).openapi({
    description: '注文商品一覧'
  }),
  shippingAddress: AddressSchema.openapi({
    description: '配送先住所'
  }),
  billingAddress: AddressSchema.optional().openapi({
    description: '請求先住所(未指定の場合は配送先と同じ)'
  }),
  total: z.number().positive(),
  status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
  createdAt: z.string().datetime()
}).openapi('Order');

バリデーションとエラーハンドリング

カスタムバリデーション

// schemas/validators.ts
import { z } from 'zod';

// カスタムバリデーター
const phoneNumber = z.string().refine(
  (val) => /^0\d{9,10}$/.test(val),
  {
    message: '有効な電話番号を入力してください'
  }
).openapi({
  description: '電話番号(ハイフンなし)',
  example: '09012345678'
});

const strongPassword = z.string()
  .min(8, '8文字以上必要です')
  .regex(/[A-Z]/, '大文字を1文字以上含める必要があります')
  .regex(/[a-z]/, '小文字を1文字以上含める必要があります')
  .regex(/[0-9]/, '数字を1文字以上含める必要があります')
  .openapi({
    description: '強力なパスワード',
    example: 'MyP@ssw0rd'
  });

// 日付範囲バリデーション
const dateRange = z.object({
  startDate: z.string().date(),
  endDate: z.string().date()
}).refine(
  (data) => new Date(data.startDate) <= new Date(data.endDate),
  {
    message: '終了日は開始日以降である必要があります',
    path: ['endDate']
  }
);

グローバルエラーハンドラー

// middleware/error-handler.ts
import { Context } from 'hono';
import { ZodError } from 'zod';

export const errorHandler = (err: Error, c: Context) => {
  // Zodバリデーションエラー
  if (err instanceof ZodError) {
    return c.json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Validation failed',
        details: err.errors.map((e) => ({
          path: e.path.join('.'),
          message: e.message
        }))
      }
    }, 400);
  }
  
  // その他のエラー
  console.error(err);
  
  return c.json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred'
    }
  }, 500);
};

// app.ts で使用
app.onError(errorHandler);

テスト

スキーマベースのテスト

// tests/schemas.test.ts
import { describe, it, expect } from 'vitest';
import { UserSchema } from '../schemas/user';

describe('UserSchema', () => {
  it('有効なユーザーデータを受け入れる', () => {
    const validUser = {
      id: '123e4567-e89b-12d3-a456-426614174000',
      name: '山田太郎',
      email: 'yamada@example.com',
      role: 'user',
      createdAt: '2024-01-01T00:00:00Z'
    };
    
    const result = UserSchema.safeParse(validUser);
    expect(result.success).toBe(true);
  });
  
  it('無効なメールアドレスを拒否する', () => {
    const invalidUser = {
      id: '123e4567-e89b-12d3-a456-426614174000',
      name: '山田太郎',
      email: 'invalid-email',
      role: 'user',
      createdAt: '2024-01-01T00:00:00Z'
    };
    
    const result = UserSchema.safeParse(invalidUser);
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.errors[0].path).toContain('email');
    }
  });
});

APIエンドポイントのテスト

// tests/api.test.ts
import { describe, it, expect } from 'vitest';
import app from '../app';

describe('User API', () => {
  it('GET /users - ユーザー一覧を取得', async () => {
    const res = await app.request('/users?page=1&limit=10');
    const data = await res.json();
    
    expect(res.status).toBe(200);
    expect(data.success).toBe(true);
    expect(Array.isArray(data.data)).toBe(true);
    expect(data.pagination).toBeDefined();
  });
  
  it('POST /users - ユーザーを作成', async () => {
    const newUser = {
      name: '田中花子',
      email: 'tanaka@example.com',
      role: 'user'
    };
    
    const res = await app.request('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newUser)
    });
    const data = await res.json();
    
    expect(res.status).toBe(201);
    expect(data.success).toBe(true);
    expect(data.data.name).toBe(newUser.name);
  });
  
  it('POST /users - バリデーションエラー', async () => {
    const invalidUser = {
      name: '',
      email: 'invalid-email'
    };
    
    const res = await app.request('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(invalidUser)
    });
    const data = await res.json();
    
    expect(res.status).toBe(400);
    expect(data.success).toBe(false);
    expect(data.error.code).toBe('VALIDATION_ERROR');
  });
});

クライアント生成

openapi-typescript-codegenの使用

npm install -D openapi-typescript-codegen
// package.json
{
  "scripts": {
    "generate:client": "openapi --input ./openapi.json --output ./src/client --client axios"
  }
}

生成されたクライアントの使用:

// client-usage.ts
import { UsersService } from './client';

// 型安全なAPI呼び出し
const users = await UsersService.listUsers({
  page: 1,
  limit: 10,
  role: 'user'
});

const user = await UsersService.getUser('123e4567-e89b-12d3-a456-426614174000');

const newUser = await UsersService.createUser({
  name: '佐藤次郎',
  email: 'sato@example.com',
  role: 'user'
});

ベストプラクティス

1. スキーマの分離と再利用

// 良い例: 小さく分割された再利用可能なスキーマ
const EmailSchema = z.string().email();
const UUIDSchema = z.string().uuid();

const UserBaseSchema = z.object({
  name: z.string(),
  email: EmailSchema
});

const UserWithIdSchema = UserBaseSchema.extend({
  id: UUIDSchema
});

2. 適切なドキュメント

const UserSchema = z.object({
  id: z.string().uuid().openapi({
    description: 'ユーザーの一意識別子',
    example: '123e4567-e89b-12d3-a456-426614174000'
  }),
  name: z.string().openapi({
    description: 'ユーザーの表示名。公開プロフィールで使用されます。',
    example: '山田太郎'
  })
});

3. バージョニング

// v1/schemas/user.ts
export const UserSchemaV1 = z.object({
  id: z.string(),
  name: z.string()
}).openapi('UserV1');

// v2/schemas/user.ts
export const UserSchemaV2 = z.object({
  id: z.string().uuid(),
  firstName: z.string(),
  lastName: z.string(),
  email: z.string().email()
}).openapi('UserV2');

まとめ

Zod + OpenAPI統合により:

  • 型安全性とドキュメントの一致を保証
  • スキーマ定義を単一の情報源に
  • 自動ドキュメント生成による開発効率向上
  • クライアントコードの自動生成
  • テストの容易性向上

適切なスキーマ設計とドキュメント化により、保守性の高いAPIを構築できます。