この記事は、トラストバンク Advent Calendar 2025の4日目です。
はじめに
ふるさとチョイスでは、フロントエンドからバックエンドにリクエストする場面において、フロントエンドチームが中間APIを作成しています。
中間APIのアーキテクチャとして、今回はHonoを採用しました。
トラストバンクがHonoを採用するまでのあれこれについては、別途機会を設けてお話しできればと思いますが、とりわけそのシンプルさが魅力での採用でした。
本記事では、トラストバンクがどのようにhonoそして@hono/zod-openapiを使っているか、一部ではありますがお伝えできればと思います。
構成
なぜ中間APIが必要か
バックエンドサーバは社内ネットワーク内でのみアクセス可能な設計になっており、クライアントから直接呼び出すことができません。そのため、外部からアクセス可能な中間層が必要でした。
また、バックエンドAPIからのレスポンスを、一度中間APIで受けて加工することで、クライアントがAPIのレスポンスに依存しない設計をつくることができます。
「APIのレスポンスが変わったことで画面が崩れた!」という不具合を防ぐことができます。
複数のAPIを1つの中間API内で実行してまとめることもできるので、フロントエンドサーバそのものへのリクエスト数の削減にも有効です。
@hono/zod-openapiは何をしてくれるのか
@hono/zod-openapiは、Zodを利用してAPIのリクエスト・レスポンスの型の作成とバリデーション、OpenAPIのスキーマの作成を同時に行うことができるライブラリです。
スキーマを変更すれば、実装にも自動的に反映されるので、修正漏れが発生しません。
従来の方法(hono + zod)
@hono/zod-openapiを使わずに、honoとzodを組み合わせた場合、以下のように実装が一般的ではないでしょうか。
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const UserRequestSchema = z.object({
id: z.string()
});
const UserResponseSchema = z.object({
id: z.number(),
name: z.string()
})
const app = new Hono();
app.get('/users/:id', zValidator('params', UserRequestSchema), async c => {
const { id } = c.req.valid('params');
const response = await fetch(`https://backend.example.com/users/${id}`);
const users = await response.json();
// レスポンスのバリデーション(手動)
const validated = UserResponseSchema.parse(user);
return c.json(validated);
});
@hono/zod-openapiを使った場合
同じ内容を@hono/zod-openapiで実装すると、以下のようになります。
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
const UserRequestSchema = z.object({
id: z.string()
});
const UserResponseSchema = z.object({
id: z.number(),
name: z.string()
});
// ルート定義(OpenAPI仕様を含む)
const createUserRoute = createRoute({
method: 'get',
path: '/users/:id',
request: {
params: { schema: UserRequestSchema }
},
responses: {
200: {
content: {
'application/json': { schema: UserResponseSchema },
},
description: '成功',
},
},
});
const app = new OpenAPIHono();
app.openapi(createUserRoute, async c => {
const { id } = c.req.valid('params');
const response = await fetch(`https://backend.example.com/users/${id}`);
const user = await response.json();
// スキーマに沿ったオブジェクトではない場合、エラーが発生する
return c.json(user);
});
今回のアーキテクチャでは、クライアント側で動作するReactと、中間APIのHonoが同じリポジトリ上にあったため、作成したスキーマはそのままクライアント側の型としても流用できました。
const { data } = useSWR<UserResponseSchema>(`/user/${id}`);
リポジトリが異なる場合には、スキーマのみを別リポジトリに切り出して配信するなどの考慮が必要かもしれません。
私たちの環境では
app.doc31('/openapi.json', {
openapi: '3.1.0',
info: {
version: '1.0.0'
}
});
app.get('/docs', context => {
/**
* @link https://redocly.com/docs/redoc/deployment/html
*/
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Redoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
Redoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="/openapi.json"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
</body>
</html>
`;
return context.html(html);
});
と実装することで、ドキュメントの自動生成まで一貫して行なっています。
デメリットはあるか
学習コストのみがデメリットでした。
チームメンバー全員がHono、Zod、OpenAPIのすべてを理解していないと開発に進めないので、最初のハードルはやや高めでした。
とはいえ、いずれもTypeScriptでの開発経験があれば難しくない内容でした。
これらに初めて触れるメンバーも、1度リリースまで行ったあとは、サクサクと実装できていたように感じます。
宣伝
トラストバンクでは一緒に働くメンバーを募集しています。
カジュアル面談からお気軽にどうぞ。
明日は@YutaManakaさんです。お楽しみに。