@hono/zod-openapi
モジュールを使用し、APIサーバーとしてHonoを利用したときにルーティングとハンドラーを分割するのに少し苦慮したので記録を残しておきます。
公式サンプルの API 実装全文
まずは、公式サンプルとして、@hono/zod-openapi モジュールを用いた API 実装の全文をご覧ください。
ここではひとつのファイルに全ての処理をまとめています。
import { serve } from '@hono/node-server'
import { OpenAPIHono } from '@hono/zod-openapi'
import { createRoute } from '@hono/zod-openapi'
import { z } from '@hono/zod-openapi'
const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '1212121',
}),
})
const UserSchema = z
.object({
id: z.string().openapi({
example: '123',
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi('User')
const route = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Retrieve the user',
},
},
})
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { id } = c.req.valid('param')
return c.json({
id,
age: 20,
name: 'Ultra-man',
})
})
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
1つのAPIルーティング宣言が長すぎる
上記のコードは、ひとつのAPIルーティングに関する定義がスキーマ定義、ルート設定、ハンドラー実装とすべてを1つのファイルにまとめて記述しており冗長です。
schema、route、handler に分割して記述したいところです。
しかし、handler 部分を単純に分離すると、c.req.valid()
の型推論が正しく効かなくなります。
具体的には'param'
や'json'
などの文字列リテラルが推論できなくなります。
型推論が効かない
ハンドラーを分離して記述する場合、例えば下記のように直接実装するとします。
export const handler = (c) => {
const { id } = c.req.valid('param')
// ...
}
この場合、c.req.valid()
の引数に何を渡しても静的解析でエラーが起こらなくなります。
型推論を効かせるための実装方法
型推論の恩恵を受けるために、@hono/zod-openapi
が提供するRouteHandler<R>
型を利用します。
import { type RouteHandler } from '@hono/zod-openapi'
import { type route } from './route.js'
export const handler: RouteHandler<typeof route> = (c) => {
const { id } = c.req.valid('param')
return c.json({
id,
age: 20,
name: 'Ultra-man',
})
}
このようにRouteHandler<typeof route>
と明示することで、routeの定義に基づいたリクエストの型情報がハンドラーに伝播されます。
これによりc.req.valid()
の使用時に適切な型推論が働くようになります。
コード全文の掲載
以下は、コードをファイル毎に分割した場合のディレクトリ構成と全文です。
ディレクトリ
$ tree -I node_modules
.
├── README.md
├── package-lock.json
├── package.json
├── src
│ ├── handler.ts
│ ├── index.ts
│ ├── route.ts
│ └── schema.ts
└── tsconfig.json
index.ts
import { serve } from '@hono/node-server'
import { OpenAPIHono } from '@hono/zod-openapi'
import { handler } from './handler.js'
import { route } from './route.js'
const app = new OpenAPIHono()
// 呼び出し元はルーティングとハンドラーを宣言するだけにとどまる
app.openapi(route, handler)
app.doc('/doc', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
handler.ts
import { type route } from './route.js'
import { type RouteHandler } from '@hono/zod-openapi'
// RouteHandlerでrouteをジェネリクス宣言することで型推論する
export const handler: RouteHandler<typeof route> = (c) => {
const { id } = c.req.valid('param')
return c.json({
id,
age: 20,
name: 'Ultra-man',
})
}
route.ts
// こちらは分割しただけ。コード変更なし。
import { createRoute } from '@hono/zod-openapi'
import { ParamsSchema, UserSchema } from './schema.js'
export const route = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Retrieve the user',
},
},
})
schema.ts
// こちらは分割しただけ。コード変更なし。
import { z } from '@hono/zod-openapi'
export const ParamsSchema = z.object({
id: z
.string()
.min(3)
.openapi({
param: {
name: 'id',
in: 'path',
},
example: '1212121',
}),
})
export const UserSchema = z
.object({
id: z.string().openapi({
example: '123',
}),
name: z.string().openapi({
example: 'John Doe',
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi('User')