Hono × Zod-OpenAPIで快適API開発
概要
最近、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
で新しいタスクを作成できる想定で実装していきます。
モデル定義
まずはタスクを表すモデルとして 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
リクエスト時に使う想定の CreateTaskSchema
や GET /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,
},
},
},
},
});
request
の schema
にモデル定義の時に実装した 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',
})
);
ここでは先ほどの tasksApi
を Nested route
として追加し Swagger ドキュメント
を生成する為の処理を追加しています。👆の場合 /api/doc
で Swagger ドキュメント
を見ることができるようになります。
最後に src/index.ts
で先程作成した api
を Nested 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 にブラウザでアクセスしてみます。
各エンドポイントと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/tasks
や POST /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 にアクセスすると認証がかかるようになります。
/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認証だけ有効になっていれば成功です!
まとめ
ModelのSchema考えて、次にAPIのSchema、実装と自然な流れで進めていける中で、Honoの実装のしやすさと相まって開発体験がとてもスムーズに感じました。認証も既存のmiddlewareを追加するだけで、サクッと実装できるのが嬉しいですね。
参考URL
Discussion