現在、Chat用BaaS Rheel Chat API の開発を進めているテックリードのminomoです。
このサービスはAPI自体が商品となるため、自然とAPIファーストで開発を進めることになり、技術選定の過程でHono + Orval + TypeSpecという組み合わせにたどり着きました。
この記事では、その技術選定の経緯と、実際のプロダクト(Rheel Chat API)での採用事例を紹介します。
この記事で分かること:
TypeSpec + Orval + Honoの開発フロー
Orvalの設定と生成されるコードの構成
実際に使ってみて感じたメリット・課題
下記については事前に決めていました。
API定義形式:OpenAPI
実行環境:Node.js
APIを提供サービスとしてするため、そのAPI仕様についてOpenAPIを使って共有することができればユーザーにメリットがあると考えたためです。
また、Node.jsについては社内でTypeScriptの知見をもつエンジニアが多かったため、採用しました。
OpenAPIのYAMLは規模が大きくなると膨大になり、保守が困難になる懸念がありました。TypeSpecであればTypeScriptに似た構文で同等の定義を簡潔に記述でき、ファイル分割で構造的に管理できるため採用しました。
OpenAPIからコード生成するツールを探したときに、一番開発が楽そうなのが、Orval × Honoの組み合わせでした。
別のHTTPフレームワーク(Express, Nest.js, Fastify)でもOpenAPIからコード生成できるツールを調査しましたが、Orval × Honoのように型生成、バリデーション、ルーター生成、Contextの型付けまで一貫して行えるようなツール見つけられませんでした。
開発の流れとしては、TypeSpecでAPIを定義し、OpenAPIにコンパイル、Orvalでコードを生成、Handlerにビジネスロジックを実装という流れになります。
TypeSpec(API定義)
↓ tsp compile
OpenAPI(中間ファイル)
↓ orval
生成コード
├── Router(ルーティング定義)
├── Zod(バリデーション)
├── Schemas(TypeScript型)
├── Context(型付きContext)
└── Handler(handlerのスタブ)
↓
Handlerにビジネスロジックを実装
TypeSpecを書くだけでRouter、バリデーション、型が全て揃い、Handler以降の実装に集中できます。
TypeSpecでモデルとAPIオペレーションを定義します。
namespace SampleAPI;
@pattern("^[a-zA-Z0-9_-]+$")
@minLength(1)
@maxLength(100)
scalar UserId extends string;
import "@typespec/http";
import "./common.tsp";
using TypeSpec.Http;
namespace SampleAPI.Users;
using SampleAPI;
/** ユーザー情報 */
model User {
/** ユーザーID(レスポンス時のみ) */
@visibility([Lifecycle.Read](<http://Lifecycle.Read>))
id: UserId;
/** 表示名 */
display_name: string;
}
import "@typespec/http";
import "../models/users.tsp";
using Http;
using SampleAPI.Users;
@route("/users")
@tag("users")
namespace SampleAPI.Users;
@get
@summary("ユーザー一覧の取得")
op getUsers(
@query @minValue(1) @maxValue(100) limit?: int32 = 20,
@query include_deleted?: boolean = false,
): User[];
@post
@summary("ユーザーの作成")
op createUser(@body body: User): User;
@visibility([Lifecycle.Read](<http://Lifecycle.Read>)) を使うことで、idフィールドがレスポンスでのみ返され、リクエストボディには含まれなくなります。これはOpenAPIのreadOnly: trueに変換されます。
TypeSpecから生成されるOpenAPI仕様です。
openapi: 3.0.0
info:
title: Sample API
version: 0.0.0
tags:
- name: users
paths:
/users:
get:
operationId: Users_getUsers
summary: ユーザー一覧の取得
tags:
- users
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: include_deleted
in: query
schema:
type: boolean
default: false
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Users.User'
post:
operationId: Users_createUser
summary: ユーザーの作成
tags:
- users
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Users.User'
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Users.User'
components:
schemas:
UserId:
type: string
minLength: 1
maxLength: 100
pattern: ^[a-zA-Z0-9_-]+$
Users.User:
type: object
required:
- id
- display_name
properties:
id:
allOf:
- $ref: '#/components/schemas/UserId'
description: ユーザーID(レスポンス時のみ)
readOnly: true # ← @visibilityの効果
display_name:
type: string
description: 表示名
// orval.config.ts
import { defineConfig } from 'orval'
export default defineConfig({
sampleApi: {
input: {
target: './openapi/openapi.yaml',
},
output: {
mode: 'tags-split',
client: 'hono',
target: 'src/generated',
schemas: 'src/generated/schemas',
override: {
hono: {
handlers: 'src/handlers',
validatorOutputPath: 'src/generated/validator.ts',
compositeRoute: 'src/generated/routes.ts',
},
zod: {
coerce: {
query: ['number'],
},
},
},
biome: true,
},
},
})
こちらは実際プロジェクトで利用している設定です。
mode: tags-split
タグごとにサブディレクトリを作成し、その中でさらにファイルを分割
schemas:スキーマの出力先
tags-splitの場合指定しないとスキーマが出力されない
compositeRoute:全タグのルートをまとめるファイル
tags-splitの場合指定しないとrouteファイルがタグごとに分かれます
validatorOutputPath:バリデーターの出力先
tags-splitの場合指定しないとファイル名が**filename.validator.ts** となり違和感があるので指定推奨
handler
handlerの出力先を指定できる(指定がない場合はtargetの中でタグごとに作成される)
biome: true
ファイル生成後にbiomeでフォーマットを行う(prettierも指定可能)
実際に生成されるファイルは下記のような形になります。
src/
├── generated/
│ ├── routes.ts # compositeRoute:全ルートをまとめる
│ ├── validator.ts # 共通zValidator
│ ├── schemas/ # スキーマ(モデルごとにファイル分割)
│ │ ├── index.ts
│ │ ├── userId.ts
│ │ ├── usersGetUsersParams.ts
│ │ └── usersUser.ts
│ └── users/ # タグごとのディレクトリ
│ ├── users.context.ts # 型付きContext
│ └── users.zod.ts # Zodスキーマ
└── handlers/ # ハンドラー(初回のみ生成、以降は編集可)
├── usersGetUsers.ts
└── usersCreateUser.ts
Orvalが生成するHonoルーター(src/generated/routes.ts)
import { Hono } from 'hono';
import { usersGetUsersHandlers } from '../handlers/usersGetUsers';
import { usersCreateUserHandlers } from '../handlers/usersCreateUser';
const app = new Hono()
app.get('/users',...usersGetUsersHandlers);
[app.post](<http://app.post>)('/users',...usersCreateUserHandlers)
export default app
compositeRouteオプションを指定すると、全ルートが1ファイルにまとめられます。指定しない場合はタグごとにusers/users.tsのようなルーターが生成されます。
Orvalが生成するZodスキーマ(src/generated/users/users.zod.ts)
import { z as zod } from 'zod';
// クエリパラメータ
export const usersGetUsersQueryLimitDefault = 20;
export const usersGetUsersQueryLimitMax = 100;
export const usersGetUsersQueryIncludeDeletedDefault = false;
export const usersGetUsersQueryParams = zod.object({
"limit": zod.coerce.number().min(1).max(usersGetUsersQueryLimitMax).default(usersGetUsersQueryLimitDefault),
"include_deleted": zod.boolean().optional()
})
// リクエストボディ - idフィールドが含まれない!(readOnlyの効果)
export const usersCreateUserBody = zod.object({
"display_name": zod.string().describe('表示名')
}).describe('ユーザー情報')
// レスポンス - idフィールドが含まれる
export const usersCreateUserResponse = zod.object({
"id": zod.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/).describe('ユーザーID'),
"display_name": zod.string().describe('表示名')
}).describe('ユーザー情報')
@visibility([Lifecycle.Read](<http://Lifecycle.Read>)) の効果により、usersCreateUserBodyにはidが含まれず、usersCreateUserResponseにはidが含まれています。
@visibility については、TypeSpecでも目玉機能の一つだと思っているので、TypeScriptの生成コードでも問題なく利用できそうで安心しました。
schemas/ディレクトリにはTypeScript interfaceが生成されます。Zodスキーマとは別に、純粋な型定義として利用できます。
// src/generated/schemas/userId.ts
/**
* @minLength 1
* @maxLength 100
* @pattern ^[a-zA-Z0-9_-]+$
*/
export type UserId = string;
// src/generated/schemas/usersUser.ts
import type { UserId } from './userId';
/**
* ユーザー情報
*/
export interface UsersUser {
/** ユーザーID(レスポンス時のみ) */
readonly id: UserId;
/** 表示名 */
display_name: string;
}
TypeSpecで定義した@visibility([Lifecycle.Read](<http://Lifecycle.Read>))は、readonly修飾子として反映されています。
Orvalは各エンドポイントに対応した型付きContextを生成します(src/generated/users/users.context.ts)。
export type UsersGetUsersContext<E extends Env = any> = Context<
E,
'/users',
{ in: { query: UsersGetUsersParams }, out: { query: UsersGetUsersParams } }
>
export type UsersCreateUserContext<E extends Env = any> = Context<
E,
'/users',
{ in: { json: NonReadonly<UsersUser> }, out: { json: NonReadonly<UsersUser> } }
>
この型付きContextをハンドラーで使用することで、c.req.valid('query')やc.req.valid('json')の戻り値に正しい型が推論されます。
Orvalが生成するハンドラーのスタブ(src/handlers/usersCreateUser.ts)
import { createFactory } from 'hono/factory';
import { zValidator } from '../generated/validator';
import { UsersCreateUserContext } from '../generated/users/users.context';
import {
usersCreateUserBody,
usersCreateUserResponse
} from '../generated/users/users.zod'
const factory = createFactory();
export const usersCreateUserHandlers = factory.createHandlers(
zValidator('json', usersCreateUserBody), // リクエストバリデーション
zValidator('response', usersCreateUserResponse), // レスポンスバリデーション
async (c: UsersCreateUserContext) => {
// ここにビジネスロジックを実装
},
);
HonoのFactoryヘルパーを利用してHandlerのスタブを作成し、下記が提供されます。
Contextにリクエストの型がつく
MiddlewareでrequestとresponseをZodでバリデーション
ここにビジネスロジックを追加していく形になります。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import routes from './generated/routes'
const app = new Hono()
app.route('/', routes)
serve({
fetch: app.fetch,
port: 3000,
})
Honoインスタンスを作成し、生成されたroutesを組み込むだけです。
新たにエンドポイントを追加した場合、そのエンドポイントはroutesの内部に追加されるため特にコードを変更する必要がありません。
また、Middlewareを組み込みたい場合は、app.route('/', routes) の手前に記述することで組み込みが可能です。
TypeSpecの機能(@visibility、@pattern、@minLength等)は全てOpenAPIに正しく変換され、Orvalでも問題なくコード生成されました。OpenAPIを介しているので当然といえば当然ですが、事前の懸念が払拭されて安心しました。
以前の記事(TypeSpecでスキーマ駆動開発)で紹介したTypeSpecの機能も問題なく利用できています。
Orvalは各エンドポイントに対応した型付きContextを生成します。
この型付きContextを使うことで、ハンドラー内でc.req.valid('query')やc.req.valid('json')を呼び出したときに、正しい型が推論されます。
async (c: UsersGetUsersContext) => {
const query = c.req.valid('query')
// 型が推論されるのでIDEの補完が効く
const limit = query.limit // number
const includeDeleted = query.include_deleted // boolean | undefined
// ...
}
レスポンスの型を指定する方法は2つあります:
生成されたTypeScript interface(UsersUser)を使う
Zodスキーマから z.infer で型を導出する(推奨)
なぜz.inferが推奨か
zValidator('response', schema)でバリデーションに使うスキーマと同じものから型を取ることで、型とバリデーションの一致が保証されます。バリデーションのコードは自動生成されるため、レスポンスにも同じスキーマを使うということをルール化できると、どの型を使えば良いか迷うこともなかったです。
import type { z } from 'zod'
import { usersCreateUserBody, usersCreateUserResponse } from '../generated/users/users.zod'
export const usersCreateUserHandlers = factory.createHandlers(
zValidator('json', usersCreateUserBody),
zValidator('response', usersCreateUserResponse), // バリデーション
async (c: UsersCreateUserContext) => {
const body = c.req.valid('json')
// バリデーションと同じZodスキーマから型を導出 → バリデーション内容と型が必ず一致
const response: z.infer<typeof usersCreateUserResponse> = {
id: crypto.randomUUID(),
display_name: body.display_name,
};
return c.json(response, 201);
},
);
Orvalが生成するハンドラーはfactory.createHandlers()の引数としてミドルウェアとハンドラーを受け取る形式なので、生成されたバリデーションの間に独自のミドルウェアを差し込むことができます。
export const usersCreateUserHandlers = factory.createHandlers(
zRequestValidatorWrapper('json', usersCreateUserBody),
apiAccessMiddleware, // -> 自作middlewareを追加
zValidator('response', usersCreateUserResponse),
async (c) => {},
)
上記の例では、Orvalが生成したバリデーションの間に、独自のアクセス制御ミドルウェアを追加しています。このように、生成されたコードをベースにしつつ、プロジェクト固有の要件に合わせて柔軟に拡張できます。
TypeSpecでAPI定義を変更してコードを再生成すると、影響範囲が型エラーとして検出されます。
例えば、Userモデルにemailフィールドを追加した場合:
// TypeSpecでフィールドを追加
model User {
id: UserId;
display_name: string;
email: string; // 追加
}
コードを再生成すると、usersCreateUserResponseのZodスキーマにemailが追加され、ハンドラー内でemailを返していない箇所が型エラーになります。
これにより、修正すべき箇所がコンパイル時に明確になるため、変更漏れを防ぐことができます。
TypeSpec + Orval + Honoの構成は、生成AIを使った開発と相性が良かったです。
生成AIは事前に埋まっている情報が多いほど、出力精度が向上します。TypeSpec + Orvalは、まさにこの「事前情報」を最大化するアプローチです。
さらにPrismaを使ってDBのスキーマを定義している場合、AIがTypeSpecの定義とPrismaの定義を参照することで穴埋め式にコードを書いてくれます。
ゼロからAIに任せてコーディングする場合にも、AIが作成するTypeSpecさえ確認しておけば、OASや型、バリデーション、routerの確認は最低限で良いためレビューも楽になります。
tags-splitモードを指定すると、タグごとにZodスキーマやContextが分割されますが、いくつかの制約があります。
Schemas
タグに関係なくschemas/にまとめて出力されます。タグはオペレーションにしか付けられないため仕方ないですが、namespaceごとに分割できると嬉しいところです。
Handler
Handlerについては生成コードの中で唯一編集するファイルになるので、プロジェクトで利用しているorval.config.tsでは出力先をhandlers/と指定して下記のような構成にしています。
generated/配下のコード = 編集しない
handlers/ 配下のコード = 編集対象
ただし、出力先ディレクトリを指定すると、tags-splitモードでもタグ単位での ディレクトリ分割ができなくなり、すべてのHandlerがフラットに配置されてしまうのが難点です。
今の所、IDEの検索機能もあるのでそこまで困っていませんが、プロジェクトが大規模になると気になってきそうです。
クエリパラメータはHTTPの仕様上、常に文字列として送られてきます。しかし、OpenAPIでintegerやbooleanと定義すると、Orvalはその型でZodスキーマを生成するため、バリデーションエラーになります。
Orvalのcoerceオプションを使うと、生成されるZodスキーマがzod.coerce.number()のように文字列から数値への変換を行ってからバリデーション行うようになり解決可能です。
// orval.config.ts
export default defineConfig({
sampleApi: {
output: {
override: {
zod: {
coerce: {
query: ['number'],
},
},
}
},
},
})
ただし、booleanには問題があります。zod.coerce.boolean()はJavaScriptのBoolean()を使うため、"false"という文字列がtrueに変換されてしまいます(空でない文字列はすべてtrue)。そのため、coerceの対象からbooleanを除外しています。
クエリパラメータのbooleanフィールドについては今の所、カスタムバリデーターで対処しています
import { z } from 'zod';
import { usersGetUsersQueryParams } from '../generated/users/users.zod'
// 生成されたスキーマを拡張し、booleanフィールドのみ上書き
const customQueryParams = usersGetUsersQueryParams.extend({
include_deleted: z
.string()
.optional()
.transform((val) => val?.toLowerCase())
.refine((val) => !val || val === 'true' || val === 'false')
.transform((val) => val === 'true'),
})
export const usersGetUsersHandlers = factory.createHandlers(
// zValidator('query', usersGetUsersQueryParams), // 削除(自動生成のバリデーション)
zValidator('query', customQueryParams), // カスタムスキーマを使用
// ...
)
TypeSpecでNamespaceを分けてモデルを定義すると、生成されるOpenAPIのスキーマ名は{Namespace}.{ModelName}となり、TypeScriptのInterfaceも{Namespace}{ModelName}のように定義されます。
// TypeSpec定義
namespace SampleAPI.Models.Users {
model User { ... }
}
# 生成されるOpenAPI
components:
schemas:
Models.Users.User: # ← ルート(SampleAPI)以降のNamespaceがプレフィックスに
type: object
// Orvalが生成するTypeScript型
export interface ModelsUsersUser { ... }
名前の衝突を避けるため、仕方のない仕様だとは思いますが、namespaceの階層を深くしてしまうとModel名が長くなり扱いにくくなりそうです。
回避策として、@friendlyNameを利用することでModel名は指定することは可能です。
ただ、各Modelに指定が必要なため小規模なプロジェクトであればnamespaceを利用せずにトップレベルに全モデルを配置するのが良さそうに思いました。
Hono + Orval + TypeSpecの組み合わせを1年ほど運用してみて、導入時はTypeSpecにより生成されたOASがOrvalと相性悪かったりしないかなどの懸念がありましたが、メリットの方が大きいと感じています。
この構成で得られたこと:
TypeSpecで簡潔にAPI定義を管理できる
Orvalで型・バリデーション・ルーターが自動生成される
Honoの柔軟なミドルウェア構成で拡張しやすい
生成AIとの相性も良く、開発効率が上がる
今回はAPIサーバー側のみの紹介でしたが、OrvalはNext.js等でのAPIクライアントの生成にも対応しています。こちらも使い勝手が良かったのでAPIを使った開発を行う場合は是非試してみて下さい。
本メディアはAPI特化の開発会社であるECU株式会社が運営しています。記事についてのご質問やAPIに関するご相談・お問い合わせはお気軽にお問い合わせフォームまで。