SPIDERPLUS Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

hono/zod-openapiで実現するAPI開発実践

🧪 @hono/zod-openapiAPI開発をネクストレベルに

前回投稿の記事、「TypeScriptとhonoで作るDI実践入門」ではHonoとTypescriptで型安全なDIを実践しました。

techblog.spiderplus.co.jp

 

しかし、前回の実装では以下の課題がありました。

  • リクエスト/レスポンスの型安全性が不十分
  • APIドキュメントを手動で管理する必要がある
  • バリデーションロジックが分散してしまう

今回はこの課題に対して、@hono/zod-openapiを導入し解決していきます。

@hono/zod-openapiを導入することで、ZodスキーマからOpenAPIドキュメントを自動生成し、リクエスト/レスポンスのバリデーション、そしてルートハンドラーのコンテキストの型安全性を更に向上させることができます。

📋 @hono/zod-openapi 導入ステップバイステップ

ここからは、既存のアプリケーションに @hono/zod-openapi を組み込み、より洗練されたAPIへと進化させる手順を見ていきましょう。

前回投稿の記事、TypeScriptとHonoで作るDI実践入門の続きとなりますので、まだ、ご覧なってない方はこちらをご確認いただくとスムーズだと思います。

1. 依存関係の追加

まずは、必要なパッケージをインストールします。
Zodはスキーマ定義・バリデーションライブラリです。

pnpm add @hono/zod-openapi zod zod-openapi

2. スキーマを定義

@hono/zod-openapi の中核となるのがZodスキーマです。
これを使って、リクエストのパラメータやレスポンスのデータ構造を厳密に定義します。

// src/schemas/UserSchema.ts
import { z } from '@hono/zod-openapi';
// Userエンティティのスキーマ定義
export const UserSchema = z.object({
  id: z.number().openapi({ example: 1, description: 'ユーザーID' }), // exampleを追加するとドキュメントが見やすくなります
  name: z.string().openapi({ example: 'Alice', description: 'ユーザー名' }),
}).openapi({ // スキーマ自体にもメタ情報を付与する
  title: 'User',
  description: 'A user entity which represents a user in the system.',
});

// リクエストパラメータのスキーマ定義 (URLのパスパラメータ用)
export const ParamsSchema = z.object({
  id: z.string() // Honoのパスパラメータはデフォルトでstring
    .regex(/^\d+$/, { message: 'ID must be a numeric string' }) // 正規表現で数値文字列であることを保証
    .openapi({
      param: {
        name: 'id',
        in: 'path', // パスパラメータであることを明示
      },
      example: '1',
      description: '取得するユーザーのID',
    }),
});

zod-openapi/extend をインポートすることで、z.number().openapi({...}) のように、各スキーマフィールドやスキーマ全体にOpenAPIドキュメント用の情報を付加できるようになります。

example や description を記述しておくと、生成されるドキュメントが非常にリッチになります。 ParamsSchema では、パスパラメータ id が数値文字列であることまでバリデーションしています。
これで、ハンドラー内で id が不正なフォーマットであるケースを早期に排除できます。

3. OpenAPIルートの定義

次に、先ほど定義したZodスキーマを使って、OpenAPIと連携する新しいルート定義を作成します。

ファイル名を userRoute.ts とし、ルーティング定義とDIミドルウェアの適用をここにまとめましょう。

// src/routes/userRoute.ts
import { createRoute } from '@hono/zod-openapi';
import { ParamsSchema, UserSchema } from '../schemas/UserSchema'; // 作成したZodスキーマ
import { userServiceDep } from '../dependencies/userServiceDependency';
import { userRepoDep } from '../dependencies/userRepositoryDependency'; // userRepositoryも使う場合は追加

// OpenAPIと連携するルートを作成
export const getUserRoute = createRoute({
  method: 'get', // HTTPメソッド
  path: '/users/{id}', // パス (プレースホルダもそのまま)
  // このルートで利用するミドルウェアを配列で指定
  middleware: [
    userRepoDep.middleware('userRepository'), // UserRepositoryも必要なら注入
    userServiceDep.middleware('userService'), // UserServiceを注入
  ] as const, // as constにより型推論が強化される
  request: {
    // リクエストパラメータのスキーマを指定
    params: ParamsSchema,
  },
  responses: {
    // 200 OK時のレスポンス定義
    200: {
      description: 'User retrieved successfully. Returns user data.',
      content: {
        'application/json': {
          schema: UserSchema, // UserSchemaがレスポンスの形式であることを示す
        },
      },
    },
    // 400 Bad Request時のレスポンス定義 (例)
    400: {
        description: 'Invalid request parameters.',
        content: {
            'application/json': {
                schema: z.object({ error: z.string() }).openapi({title: 'ErrorResponse'}),
            }
        }
    },
    // 404 Not Found時のレスポンス定義 (例)
    404: {
        description: 'User not found.',
        content: {
            'application/json': {
                schema: z.object({ error: z.string() }).openapi({title: 'ErrorResponse'}),
            }
        }
    }
  },
  // タグやサマリーも追加してドキュメントを分かりやすく
  tags: ['Users'],
  summary: 'Get a single user by ID',
});

createRoute 関数は HTTPメソッド、パス、リクエスト/レスポンスのスキーマミドルウェアまで、ルートに関する情報を一元的に定義できます。

特に注目すべきは middleware: [ ... ] as const です。

as const を付けることで、ミドルウェアの配列の型がリテラル型として推論され、後述するハンドラーでの c.var の型安全性が飛躍的に向上します。

hono-simple-di のミドルウェアもここで指定することで、このルート専用のDIコンテキストが作られるイメージです。

responses に各ステータスコードごとのレスポンススキーマを定義することで、生成されるOpenAPIドキュメントが非常に詳細になり、クライアント開発者との連携もスムーズになります。

4. 型安全なハンドラーの実装

先ほど定義した getUserRoute の型情報をフル活用した、完全に型安全なハンドラーを実装しましょう。 ファイル名を fetchUserHandler.ts とします。

// src/handler/fetchUserHandler.ts
import { RouteHandler } from "@hono/zod-openapi"; // RouteHandler型をインポート
import { getUserRoute } from "../routes/userRoute"; // 作成したOpenAPIルート定義
// import { User } from "../interfaces/User"; // 必要に応じて

// getUserRouteの型情報を元に、厳密に型付けされたハンドラーを作成
const handler: RouteHandler = async (c) => {
  // c.req.valid('param') で、型安全にバリデーション済みパラメータを取得!
  // ParamsSchemaで定義した型 (ここでは { id: string }) で取得できる
  const { id } = c.req.valid('param');

  // c.var.userService も、getUserRouteのmiddleware定義に基づいて型付けされる!
  const user = c.var.userService.getUserById(Number(id));

  if (!user) {
    // Zodスキーマに沿ったエラーレスポンスを返す (この例では省略していますが、本来はスキーマに合わせたオブジェクトを生成)
    return c.json({ error: 'User not found' }, 404);
  }

  // レスポンスも UserSchema に基づいて型チェックされる (return c.json(user) の user が UserSchema に合致するか)
  return c.json(user);
};

export default handler;

RouteHandler という型注釈に注目してください。 c オブジェクトの型が getUserRoute の定義に基づいて推論されます。

  • c.req.valid('param'):もともと、c.req.param('id') の戻り値が string | undefined でしたが、今回 Zodによるバリデーション済みのパスパラメータが、ParamsSchema で定義した通りの型で取得できます。
    不正なリクエストは、このハンドラーに到達する前にエラーレスポンスが返されます。
  • c.var.userService: getUserRoutemiddleware で指定した依存関係 (userService) が、UserService 型として完璧に型推論されます。
    以前 src/types/hono.d.ts で行った ContextVariableMap の拡張は、@hono/zod-openapi を使うルートでは不要になるケースが多いです(グローバルに使いたい場合は依然有効)。

この対応で型の恩恵を最大限に受けながら、ビジネスロジックに集中できます。
↓従前までの内容: 対応前はcの型が推論されず、any型となっていた

↓今回の対応: cの型が推論され見通しが良くなった

5. OpenAPIHonoアプリケーションの設定

最後に、index.ts (または app.ts を修正して index.ts から呼び出す形でもOK) を変更します。
Hono の代わりに OpenAPIHono を使用し、定義したルートとハンドラーを登録します。

// src/index.ts (または app.ts を修正してこちらをエントリーポイントに)
import { serve } from '@hono/node-server';
import { OpenAPIHono } from '@hono/zod-openapi'; // OpenAPIHonoをインポート
import { getUserRoute } from './routes/userRoute'; // OpenAPIルート定義
import fetchUserHandler from './handler/fetchUserHandler'; // 型安全ハンドラー

// OpenAPIHonoインスタンスを作成
const app = new OpenAPIHono();

// 通常のHonoと同様にルートを定義することも可能
app.get('/', (c) => {
  return c.json({
    message: 'Hono DI Application with OpenAPI へようこそ!',
    endpoints: {
      users: '/users/{id}',
      docs: '/doc' // OpenAPIドキュメントのエンドポイント
    }
  });
});

// OpenAPI用のユーザールートを登録
// 第1引数にルート定義、第2引数にハンドラーを渡す
app.openapi(getUserRoute, fetchUserHandler);

// OpenAPI ドキュメント (Swagger UI) のエンドポイントを追加
app.doc('/doc', {
  openapi: '3.0.0', // OpenAPIのバージョン
  info: {
    version: '1.0.0',
    title: 'My Super User API', // APIのタイトル
    description: 'An API to manage users, built with Hono and OpenAPI.', // APIの説明
  },
  // servers: [{ url: 'http://localhost:3000', description: 'Development server' }] // サーバー情報も追加可能
});

const port = 3000;
console.log(`Server is running on http://localhost:${port}`);
console.log(`OpenAPI docs available at http://localhost:${port}/doc`); // Swagger UIへの案内!

serve({
  fetch: app.fetch,
  port
});

OpenAPIHono クラスを使用し、app.openapi(getUserRoute、 fetchUserHandler) のようにして、定義済みのルートとハンドラーを結びつけます。
そして、app.doc('/doc', {...}) で、Swagger UI形式のAPIドキュメントを /doc エンドポイントで公開することができます。

これで、再度 pnpm dev でサーバーを起動し、http://localhost:3000/doc にアクセスしてみてください。
美しいSwagger UIが表示され、定義したスキーマ情報が反映されたAPIドキュメントが自動生成されているはずです。

もちろん、/users/1 などのエンドポイントも以前と同様に動作します。

🎯 @hono/zod-openapi 導入によるメリット・デメリット

@hono/zod-openapi を導入する価値を改めて整理しましょう。

  1. 型安全性:
    • c.req.valid('param') による、バリデーション済みかつ正確に型付けされたリクエストデータの取得。
    • ルート定義に紐付いたミドルウェア (c.var) の型推論が強化され、手動メンテナンス箇所が減少。
    • レスポンスの型もZodスキーマで定義できるため、意図しないデータ構造のレスポンスを防ぎ、APIの信頼性が向上する。
  2. 自動ドキュメント生成:
    • /doc エンドポイントにアクセスするだけで、リッチなSwagger UIが利用可能。APIの仕様が常に最新のコードと同期します。
  3. 強力かつ柔軟なバリデーション:
    • Zodの豊富なバリデーションルール(文字列、数値、日付、オブジェクト、配列、etc.)カスタムバリデーションを駆使して、堅牢な入力チェックができる。
    • エラーメッセージも細かくカスタマイズ可能。
  4. 開発効率の向上:
    • エディタの強力な型推論とオートコンプリートにより、コーディングミスが減少。
    • APIの仕様が明確になることで、フロントエンド開発者やAPI利用者とのコミュニケーションコストも削減できる。
  5. OpenAPI仕様準拠でAPIの標準化:
    • 業界標準のOpenAPI仕様に基づいたAPI設計は、将来的なツール連携やエコシステムの活用にも繋がります。

一方、@hono/zod-openapi導入の潜在的なデメリット・注意点についても解説します。

  • 学習コスト:
      • HonoとZodに加えて、@hono/zod-openapiが提供する独自のAPI定義方法や、.openapi()といったスキーマ拡張の記述方法を新たに学ぶ必要があり、導入への障壁となるかもしれません。
  • コード記述量の増加
    • リクエストのパスパラメータ、クエリ、レスポンス等の各ステータスコードごとにスキーマを定義していく作業は、特に小規模なAPIやプロトタイピングの段階では「冗長」と感じられるかもしれません。

🎉 まとめ

本記事では、HonoとTypeScriptをベースに、@hono/zod-openapiを活用した型安全でドキュメント化されたAPI開発を、ハンズオン形式で解説しました。

@hono/zod-openapiの導入は、小規模なアプリケーションではやや冗長に映るかもしれません。
しかし、TypeScriptや hono-simple-diと組み合わせることで得られる、厳格な型安全性やAPIドキュメントの自動生成といったメリットは、その手間を上回る価値があると思います。

これにより、アプリケーションのメンテナンス性は大幅に向上し、開発体験もより快適なものになるはずです。ぜひ本ガイドを参考に、このパワフルな開発手法を体験してみてください。

また、「TypeScriptとHonoで作るDI実践入門」と「ソフトウェアアーキテクチャの本質と依存関係の整理、あるいはinterfaceの正しい使い所について」と合わせて読むことでより一層理解が深まると思うので、こちらもご一読いただけると幸いです。

techblog.spiderplus.co.jp

最後になりますが、スパイダープラスでは一緒に働ける仲間を募集しております。
スパイダープラスに少しでも興味を持たれた方は是非、カジュアル面談などでお話をしてみませんか?ご連絡をお待ちしております。