🚀

Hono × Zod-OpenAPIで快適API開発

2024/09/22に公開

概要

最近、HonoとZodを使ってOpenAPIベースのAPIを構築する機会があり、その体験が良かったため、記事としてまとめました。本記事では、HonoとCloudflare Workersを組み合わせた構成を前提に、APIを作成する手順やポイントを紹介しています。他にも良い方法やアプローチがあれば、ぜひご意見をいただけると嬉しいです。

動作環境

  • node: 22.7.0
  • yarn: 1.22.22

環境構築

Hono + Cloudflare Workers プロジェクト作成

早速プロジェクト作っていきます。 create hono でtemplateに cloudflare-workers を選択します。

$ yarn create hono .
✔ Using target directory … .
? Which template do you want to use? cloudflare-workers
? Directory not empty. Continue? yes
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? yarn
✔ Installing project dependencies
🎉 Copied project files

Dockerを使っている場合 wrangler.toml に👇を追加しときます。

[dev]
ip = "0.0.0.0"
port = 8787

yarn install & yarn dev で起動し、http://localhost:8787/ にアクセスし Hello Hono! が表示されとけばOKです。

必要なパッケージ追加

ドキュメントを表示する為の @hono/swagger-ui も追加します。

yarn add @hono/zod-openapi @hono/swagger-ui

今回作成するAPIの概要

タスク管理アプリのAPIを想定し、タスクの取得と作成を行えるようなAPIを実装します。 GET /api/tasks でタスクのリストを取得し、POST /api/tasks で新しいタスクを作成できる想定で実装していきます。

image1.png

モデル定義

まずはタスクを表すモデルとして src/models/task.ts を以下内容で作成します。

import { z } from '@hono/zod-openapi';

export const TaskSchema = z
  .object({
    uuid: z.string().openapi({
      example: '12345678-e29b-41d4-a716-123456789000',
    }),
    title: z.string().openapi({
      example: 'Title',
    }),
    description: z.string().openapi({
      example: 'Description',
    }),
    completed: z.boolean().openapi({
      example: false,
    }),
    priority: z.number().min(1).max(5).openapi({
      example: 3,
    }), // タスクの優先度(1〜5)
  })
  .openapi('TaskSchema');

// 新しいタスク作成用のリクエストモデル
export const CreateTaskSchema = z
  .object({
    title: z.string().openapi({
      example: 'Title',
    }),
    description: z.string().openapi({
      example: 'Description',
    }),
    priority: z.number().min(1).max(5).default(3).openapi({
      example: 3,
    }),
    completed: z.boolean().default(false).openapi({
      example: false,
    }),
  })
  .openapi('CreateTaskSchema');

// タスクのレスポンスモデル
export const TaskListSchema = z.array(TaskSchema).openapi('TaskListSchema');

POST /api/tasks リクエスト時に使う想定の CreateTaskSchemaGET /api/tasks でのレスポンス用の TaskListSchema も定義しています。

ついでにエラー用のレスポンスSchemaも定義しておきます。src/models/error.ts を以下内容で作成します。

import { z } from '@hono/zod-openapi';

export const ErrorResponse = z
  .object({
    message: z.string(),
    stackTrace: z.string().optional(),
  })
  .openapi('ErrorResponse');

各エンドポイント実装

GET /api/tasks エンドポイント

新規に src/api/tasks/getTasks.ts ファイルを作成し、OpenAPI のルート定義を行います。ここでは、@hono/zod-openapi パッケージの createRoute メソッドを使用します。

import { createRoute } from '@hono/zod-openapi';
import { ErrorResponse, TaskListSchema } from '../../models';

export const getTasksRoute = createRoute({
  path: '/',
  method: 'get',
  description: '登録されているすべてのタスクのリストを取得します',
  responses: {
    200: {
      description: 'OK',
      content: {
        'application/json': {
          schema: TaskListSchema,
        },
      },
    },
    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: ErrorResponse,
        },
      },
    },
  },
});

ここでは、HTTPステータスコード 200 の成功時のレスポンスに、src/models/task.ts で定義した TaskListSchema を使用しています。加えて、500 エラーが発生した場合には、src/models/error.ts で定義した ErrorResponse を返す設定を行っています。

次に実際に何らかの処理を行い、タスク一覧を返す処理の実装を行います。今回はサンプルの為、固定データを返すようにしています。

type TaskSchema = z.infer<typeof TaskSchema>;

export const getTasksHandler: RouteHandler<typeof tasksRoute, {}> = async (c) => {
  try {
    const tasks: TaskSchema[] = [
      {
        uuid: '12345678-e29b-41d4-a716-123456789000',
        title: 'Buy Groceries',
        description: 'Purchase milk, eggs, and bread from the store',
        completed: false,
        priority: 2,
      },
      {
        uuid: '23456789-c23e-59c3-c234-234567890111',
        title: 'Morning Run',
        description: 'Run 5 kilometers in the park',
        completed: false,
        priority: 5,
      },
    ];

    return c.json(tasks, 200);
  } catch (e) {
    console.error(e);
    return c.json({ message: 'Internal Server Error', stackTrace: e }, 500);
  }
};

POST /api/tasks エンドポイント

新規に src/api/tasks/createTasks.ts ファイルを作成します。GETの時と同じようにまずはOpenAPI のルート定義を行います。

import { createRoute } from '@hono/zod-openapi';
import { CreateTaskSchema, ErrorResponse, TaskSchema } from '../../models';

type CreateTaskSchema = z.infer<typeof CreateTaskSchema>;

export const createTasksRoute = createRoute({
  path: '/',
  method: 'post',
  description: '新たにタスクを登録します',
  request: {
    body: {
      content: {
        'application/json': {
          schema: CreateTaskSchema,
        },
      },
    },
  },
  responses: {
    200: {
      description: 'OK',
      content: {
        'application/json': {
          schema: TaskSchema,
        },
      },
    },
    500: {
      description: 'Internal Server Error',
      content: {
        'application/json': {
          schema: ErrorResponse,
        },
      },
    },
  },
});

requestschema にモデル定義の時に実装した CreateTaskSchema を設定しています。

次に実際の処理部分を実装していきます。こちらも本来はDB等に保存したりと処理を行うかと思いますが、今回は受け取ったデータにuuidを発行して返すようにしています。

type TaskSchema = z.infer<typeof TaskSchema>;

export const createTasksHandler: RouteHandler<
  typeof createTasksRoute,
  {}
> = async (c) => {
  try {
    const newTask = await c.req.json<CreateTaskSchema>();
    const uuid = crypto.randomUUID();
    const task: TaskSchema = {
      uuid,
      ...newTask,
    };

    return c.json(task, 200);
  } catch (e) {
    console.error(e);
    return c.json({ message: 'Internal Server Error', stackTrace: e }, 500);
  }
};

Router定義

src/api/tasks/index.ts を以下内容で作成します。

import { OpenAPIHono } from '@hono/zod-openapi';
import { createTasksHandler, createTasksRoute } from './createTask';
import { getTasksHandler, getTasksRoute } from './getTasks';

export const tasksApi = new OpenAPIHono();
tasksApi
  .openapi(getTasksRoute, getTasksHandler)
  .openapi(createTasksRoute, createTasksHandler);

ここでは作成した GET /api/tasks エンドポイント (getTasks)POST /api/tasks エンドポイント (createTask)tasksApi というRouteに設定しています。

次に src/api/index.ts を以下内容で作成します。

import { swaggerUI } from '@hono/swagger-ui';
import { OpenAPIHono } from '@hono/zod-openapi';
import { tasks, tasksRoute } from './tasks';

export const api = new OpenAPIHono();

api
  .route('/tasks', tasksApi)
  .doc('/specification', {
    openapi: '3.0.0',
    info: {
      title: 'API',
      version: '1.0.0',
    },
  })
  .get(
    '/doc',
    swaggerUI({
      url: '/api/specification',
    })
  );

ここでは先ほどの tasksApiNested route として追加し Swagger ドキュメント を生成する為の処理を追加しています。👆の場合 /api/docSwagger ドキュメント を見ることができるようになります。

最後に src/index.ts で先程作成した apiNested route として追加します。

(また、JSON レスポンスを見やすくする prettyJSON と 404 の設定も行なっています)

import { Hono } from 'hono';
import { prettyJSON } from 'hono/pretty-json';
import { api } from './api';

const app = new Hono();
app.use(prettyJSON());
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404));

app.route('/api', api);

export default app;

動作確認

Swagger ドキュメント

これで準備ができたので、動作確認していきたいと思います。まずは Swagger ドキュメント がちゃんと表示されるか http://localhost:8787/api/doc にブラウザでアクセスしてみます。

image2.png

各エンドポイントとSchemasが反映されたドキュメントが生成されていればOKです。

レスポンス確認

最後にリクエストを送ってレスポンスを確認してみます。

まずは GET /api/tasks エンドポイント を試してみます。

$ curl -i 'http://localhost:8787/api/tasks?pretty'                                   main
HTTP/1.1 200 OK
Content-Length: 385
Content-Type: application/json; charset=UTF-8

[
  {
    "uuid": "12345678-e29b-41d4-a716-123456789000",
    "title": "Buy Groceries",
    "description": "Purchase milk, eggs, and bread from the store",
    "completed": false,
    "priority": 2
  },
  {
    "uuid": "23456789-c23e-59c3-c234-234567890111",
    "title": "Morning Run",
    "description": "Run 5 kilometers in the park",
    "completed": false,
    "priority": 5
  }
]

ちゃんと値が返ってきてそうです ✨

次に POST /api/tasks エンドポイン を試してみます。

$ curl -i -X POST 'http://localhost:8787/api/tasks?pretty' -H 'Content-Type: application/json' -d '{"title":"買い物", "description":"今日の買い物", "priority": 1, "completed": false}'
HTTP/1.1 200 OK
Content-Length: 154
Content-Type: application/json; charset=UTF-8

{
  "uuid": "104685a2-9728-488b-8505-0b64a426329a",
  "title": "買い物",
  "description": "今日の買い物",
  "priority": 1,
  "completed": false
}

こちらも意図した値が返ってきていそうです。ここでリクエストデータの title が無い状態でリクエストを送ってみます。

$ curl -i -X POST 'http://localhost:8787/api/tasks?pretty' -H 'Content-Type: application/json' -d '{"description":"今日の買い物", "priority": 1, "completed": false}'  
HTTP/1.1 400 Bad Request
Content-Length: 274
Content-Type: application/json; charset=UTF-8

{
  "success": false,
  "error": {
    "issues": [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "undefined",
        "path": [
          "title"
        ],
        "message": "Required"
      }
    ],
    "name": "ZodError"
  }
}

ちゃんとValidationエラーが返ってきました ✨

認証

最後に /api/doc に Basic認証を、それ以外の GET /api/tasksPOST /api/tasks に Bearer認証をつけてみたいと思います。

/api/docへのBasic認証

Router定義の時点で作成した src/api/tasks/index.ts に対して、以下を追加します。

api
  .route('/tasks', tasksApi)
  .doc('/specification', {
    openapi: '3.0.0',
    info: {
      title: 'API',
      version: '1.0.0',
    },
  })
  // ↓ここから追加
  .use('/doc/*', async (c, next) => {
    const auth = basicAuth({
      username: 'user', // 本来は環境変数等でちゃんと値を設定
      password: 'pass', // 今回は固定
    });
    return auth(c, next);
  })
  // ここまで
  .get(
    '/doc',
    swaggerUI({
      url: '/api/specification',
    })
  );

こうすることで http://localhost:8787/api/doc にアクセスすると認証がかかるようになります。

image3.png

/api/tasksへのBearer認証

今度は GET/PODT /api/tasks (/api/doc は除く) リクエスト時にBearer認証をつけてみたいと思います。

src/api/index.ts に以下を追加します。

export const api = new OpenAPIHono();
// ↓追加
// /api/doc以外はBearer認証をかける
api.use('/*', async (c, next) => {
  if (c.req.path === '/api/doc' || c.req.path === '/api/specification') {
    return next();
  }
  const auth = bearerAuth({
    token: 'token', // デモ用なので固定
  });
  return auth(c, next);
});
// ここまで

他に良い方法があるかもしれませんが、思いつく方法で試してみました。

(他の方法をご存知の方は教えて頂けると助かります 🙏)

この状態で GET /api/tasks エンドポイント にリクエストを投げてみます。

$ curl -i 'http://localhost:8787/api/tasks?pretty'                                   main
HTTP/1.1 401 Unauthorized
Content-Length: 12
Content-Type: text/plain;charset=UTF-8
WWW-Authenticate: Bearer realm=""

Unauthorized

401 が返ってきてます。次に token を付与してリクエストしてみます。

$ curl -i -H 'Authorization: Bearer token' 'http://localhost:8787/api/tasks?pretty'    
HTTP/1.1 200 OK
Content-Length: 385
Content-Type: application/json; charset=UTF-8

[
  {
    "uuid": "12345678-e29b-41d4-a716-123456789000",
    "title": "Buy Groceries",
    "description": "Purchase milk, eggs, and bread from the store",
    "completed": false,
    "priority": 2
  },
  {
    "uuid": "23456789-c23e-59c3-c234-234567890111",
    "title": "Morning Run",
    "description": "Run 5 kilometers in the park",
    "completed": false,
    "priority": 5
  }
]

ちゃんと返ってきてます ✨

/api/doc も試してみます。 http://localhost:8787/api/doc にアクセスしてBasic認証だけ有効になっていれば成功です!

image4.png

まとめ

ModelのSchema考えて、次にAPIのSchema、実装と自然な流れで進めていける中で、Honoの実装のしやすさと相まって開発体験がとてもスムーズに感じました。認証も既存のmiddlewareを追加するだけで、サクッと実装できるのが嬉しいですね。

参考URL

https://tech.fusic.co.jp/posts/hono-zod-openapi/

https://zenn.dev/praha/articles/d1d6462a27e37e

Discussion