Honoを使い倒したい2024
はじめに
こんにちは、AI Shift バックエンドエンジニアの@sugar235711です。
この記事では、Honoの使い方をおさらいし、API開発を通じてHonoの実際の開発で役立つTipsを紹介します。
Honoの基本的なコンセプトや網羅的な実装例については、公式ドキュメントを参照してください。
更新情報
2024/7/29更新
基本編
この章では、Honoの基本的な使い方を紹介します。
※Hono: v4.3系
App/Contextオブジェクトの使い方
Honoでは、プライマリオブジェクトであるHonoインスタンスを生成し、そのインスタンスをもとにAPIのエンドポイントを定義します。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hono!'))
export default app
Honoにはリクエスト/レスポンスを扱いやすくするContextというオブジェクトがあります。上記の例で言えば、c
がContextオブジェクトです。
このContextオブジェクトは、ヘッダーやリクエストボディ、レスポンスボディなどの操作を行うためのメソッドを提供します。さらに、環境変数やカスタムミドルウェアを設定することもできます。
type Env = {
Variables: {
hoge: () => void
}
}
const app = new Hono<Env>()
const middleware = createMiddleware<Env>(async (c, next) => {
c.set('hoge', () => {console.log("fuga")}) // Contextにhoge関数を追加
await next()
})
app.use(middleware)
app.get('/hello', (c) => {
const { hoge } = c.var
hoge() // output: fuga
return c.text('Hello, Hono!')
})
上記のように、Hono<Env>
とすることで、関数等をContext経由で型安全に別メソッドに伝播することができます。
さて、このままでも十分便利ですが、実際にAPIを作成する際はカスタムのHonoインスタンス(Factory)を作成して使い回すと、ファイル分割等を行った際に便利です。
- ReturnTypeを使用し、型付けがされたHonoインスタンスを返す関数を作成します。
export const newApp = () => {
const app = new Hono<HonoEnv>();
app.use(prettyJSON());
app.onError(handleError);
// ....
return app;
}
export type App = ReturnType<typeof newApp>;
- 作成したHonoインスタンスを使ってAPIを作成します。
export const hogeApi = (app: App) => {
app.get('/hoge', (c) => c.text('hoge'))
}
- Honoインスタンスをシングルトンで使い回します。
import { newApp } from './customHono';
import { hogeApi } from './routes/hoge';
const app = newApp();
hogeApi(app)
//...
上記のように自分で定義することも可能ですが、Honoの公式ヘルパーとしてFactoryメソッドが用意されているので、それらを使用するのも良いでしょう。
また、Hono作者の@yusukebeさんも型付きのAppを返す方法を紹介しています。
Hono v4.5.0以降ではtype
だけでなくinterface
もBindingやVariablesに直接使用できるようになりました。
この変更により、wrangler cliにより生成した型をそのまま使用できるなど、Cloudflareとの親和性が向上しています。
ミドルウェアの設計
APIにおけるミドルウェアの責務は、ユーザー認証・認可、ロギング、キャッシュ等の共通処理を行うことです。
Honoでは、ビルトインのミドルウェアや、自作のカスタムミドルウェアを使用することが可能です。
const example = (): MiddlewareHandler => {
return async (c, next) => {
console.log('middleware start')
await next()
}
}
// ---or---
import { createMiddleware } from 'hono/factory'
const example = createMiddleware(async (c, next) => {
console.log('middleware start')
await next()
})
// usage entrypoint.ts
const app = new Hono()
app.use(example()) // custom middleware
app.use('/posts/*', cors()) // built-in middleware
app.post('/posts', (c) => c.text('Created!', 201))
ミドルウェアは定義順に実行されるため、以下のような実行順序になります。
example() -> cors() -> post handler
next
の後に処理を追加し、スタックのような処理を実現することも可能です。詳細については、公式ドキュメントを参照してください。
このミドルウェアの中では任意の処理を行うことができますが、基本的には1リクエストのスコープに閉じた動作を行うべきです。例えば、ユーザーID等のリクエストごとに一意に扱いたい情報をContextに詰めて伝播させるなどです。
下記はリクエスト時に初期化を行うミドルウェアの例です。
リクエストごとに一意なIDとロガーを生成してContextに詰めています。
export const init = (): MiddlewareHandler<HonoEnv> => {
return async (c, next) => {
const logger = new AppLogger({
requestId: uuidv4(),
});
c.set("services", {
logger: logger
});
logger.info("[Request Started]");
await next();
};
}
上記のように任意のオブジェクトをContextを通してhandlerから安全に利用することが可能になります。
Combine Middleware
Hono v4.5.0以降ではCombine Middleware
を使用することで、複数のミドルウェアを組み合わせて扱えるようになります。
下記を組み合わせることでミドルウェアの実行を制限できます。実行はミドルウェアの定義順です。
some
- 複数のミドルウェアのうち一つ実行
every
- 複数のミドルウェアを全て実行
except
- 指定したミドルウェアを除外して実行
例えばあるエンドポイントを保護する場合に、IP制限またはBearer認証のどちらかを通過するといった処理を行うことができます。
例えばメンテナンスページ以外では、ミドルウェアを実行したり、
app.use('*', except(['/maintenance'], middleware1))
IP制限に引っかかった場合は、Bearer認証を行うといった制御も簡単に記述することが可能です。
app.use(
'*',
some(
ipRestriction(getConnInfo, { allowList: ['192.168.0.2'] }),
bearerAuth({ token })
)
)
エラーハンドリングについて
Honoでは、onError
メソッドによりスローされたエラーをキャッチし、トップレベルでエラーハンドリングを行うことができます。
import { HTTPException } from 'hono/http-exception'
// ...
app.onError((err, c) => {
console.error(`${err}`)
return c.text('Custom Error Message', 500)
})
app.post('/auth', async (c, next) => {
// authentication
if (authorized === false) {
throw new HTTPException(401, { message: 'Custom error message' })
}
await next()
})
上記のようにHonoにはHTTPException
クラスが用意されており、認証時のエラーなどのHTTPステータスコードを指定してエラーをスローすることが推奨されています。
エラーハンドリングの例
より実践的な例として、Honoのカスタムインスタンス内にonErrorメソッドを定義し、各エラータイプごとに処理を分けるハンドラーを定義してエラーハンドリングを行います。
export const newApp = () => {
const app = new Hono();
app.onError(handleError); // エラーハンドリング
// ....
return app;
}
export const handleError = (err: Error, c: Context<HonoEnv>): Response => {
const { logger } = c.get("services");
if (err instanceof HTTPException) {
if (err.status >= 500) {
logger.error("HTTPException", {
message: err.message,
status: err.status,
requestId: c.get("requestId"),
});
}
const code = statusToCode(err.status);
return c.json<ErrorResponse, StatusCode>(
{
error: {
code,
message: err.message,
requestId: c.get("requestId"),
},
},
{ status: err.status },
);
}
logger.error("unhandled exception", {
name: err.name,
message: err.message,
cause: err.cause,
stack: err.stack,
requestId: c.get("requestId"),
});
return c.json<ErrorResponse, StatusCode>(
{
error: {
code: "INTERNAL_SERVER_ERROR",
message: err.message ?? "something unexpected happened",
requestId: c.get("requestId"),
},
},
{ status: 500 },
);
}
バリデーションエラーについて
致命的なエラー以外にも、バリデーションエラーなどのエラーをハンドリングすることがあります。
HonoではZodやVailbotなどのライブラリを組み合わせ、リクエストのスキーマに対するバリデーションを簡単に実装することができます。
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
app.post(
'/users/new',
zValidator('json', userSchema, (result, c) => {
if (!result.success) {
return c.text('Invalid!', 400)
}
}),
async (c) => {
const user = c.req.valid('json')
console.log(user.name) // string
console.log(user.age) // number
}
)
後述するOpenAPIHonoとdefaultHooksを組み合わせることで、zodエラーメッセージを加工する例を紹介します。
環境変数について
昨今ではJavaScriptを取り巻く環境が多様化しており、環境変数の読み込み方も多様化しています。
例えば、下記のような主要なランタイムでは、それぞれ異なる環境変数の読み込み方法があります。
-
Workerd
- wrangler.toml/.dev.vars
-
Deno
- Deno.env
- .envファイル
-
Bun
- Bun.env
- process.env
-
Node
- process.env
例えば、仮にCloudflare Workersで開発していたアプリケーションをコンテナ化して別のランタイムで動かす場合、環境変数の読み込み方法が異なるためコードを書き換える必要があります。
このような環境ごとに際を減らすため、Honoではランタイムによらず環境変数を読み込むためのヘルパーが用意されています。
import { env } from 'hono/adapter'
app.get('/env', (c) => {
const { NAME } = env<{ NAME: string }>(c)
return c.text(NAME)
})
Hono Adapterを通すことでランタイムによらない環境変数の読み込みを行うことが可能となります。
さらに、下記のようにzodと組み合わせることで型安全に環境変数の値を取り出すことができます。
import { env } from 'hono/adapter'
import { z } from 'zod'
const zEnv = z.object({
HOGE_API_KEY: z.string()
});
type Env = z.infer<typeof zEnv>;
// middleware
const init = (): MiddlewareHandler<HonoEnv> => {
return async (c, next) => {
const honoEnv = env<Env, AppContext>(c)
const envResult = zEnv.safeParse(honoEnv)
if (!envResult.success) {
console.error('Failed to parse environment variables', envResult.error)
return
}
// ....
}
}
構造化ロギングについて
一般的に構造化ロギングを行う際に、RequestIDを付与したLoggerを作成することが多いです。
しかし、Node.js等の環境ではシングルスレッドで動作するため、非同期処理を行う際にグローバルにRequestIDを保持することが難しいです。そのため、AsyncLocalStorageを組み合わせることでこれを回避することができます。
const asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>();
export function runWithRequestId(requestId: string, fn: () => void) {
const store = new Map<string, any>();
store.set('requestId', requestId);
asyncLocalStorage.run(store, fn);
}
export function getRequestId(): string | undefined {
const store = asyncLocalStorage.getStore();
return store?.get('requestId');
}
getRequestId()
を任意の場所で呼び出すことで、リクエストごとに一意なRequestIDを保持したLoggerを作成できます。
上記が一般的なロギングのプラクティスですが、HonoではContextを通して伝播させることでこの問題を解決することができます。
例えば、ミドルウェアの設計で紹介した初期化処理でLoggerをContextに詰めることで、リクエストごとのLoggerを保持することができます。
// middleware
export const init = (): MiddlewareHandler<HonoEnv> => {
return async (c, next) => {
const logger = new AppLogger({
requestId: uuidv4(), // 一意なRequestIDを生成
});
c.set("services", {
logger: logger
});
logger.info("[Request Started]");
await next();
};
}
// handler
app.get('/hello', (c) => {
const { logger } = c.get("services");
logger.info("Hello, Hono!"); // リクエストごとのLoggerを使用
return c.text('Hello, Hono!')
})
ただし、実務ではクリーンアーキテクチャに則り、レイヤー分けがされている場合が多く、レイヤー間でContextを伝播させない選択をする場合は依然としてAsyncLocalStorageを使用することになると思います。
オブザーバビリティについて
オブザーバビリティはログ、トレース、メトリクスの3つの観点からシステムの内部状態を可視化し、観測性を向上させることです。
OpenTelemetry[1]のSDKと組み合わせることでHonoでもトレースを取ることができます。ロギング同様に、Context経由でTracerを伝播させることで、リクエストごとのトレースを取ることができます。
import { Tracer } from "@opentelemetry/api";
type ServiceContext = {
tracer: Tracer
// ...
};
type HonoEnv = {
Variables: {
services: ServiceContext;
};
};
app.get('/hoge', (c) => {
const { tracer } = c.get("services");
const result = await tracer.startActiveSpan(`business-logic`, async (span) => {
const result = await businessLogic()
span.setAttributes(result)
return result
});
return c.json(result, 200)
})
より実践的な例として、Cloudflare Workersで計装を行えるOpenTelemetryライブラリを紹介します。
このライブラリとHonoを組み合わせ、Cloudflare Workers上で簡単に計装を行うことが可能です。下記ではランダムに複数ユーザーを登録するAPIを計装しています。(わざとBulk Insertをしていません)export const registerUserRandomPostApi = (app: App) =>
app.openapi(postUsersRoute, async (c: AppContext) => {
// ...
const { db, tracer } = c.get("services");
return tracer.startActiveSpan('registerUserRandomPostApi', async (span) => {
const dbSpan = tracer.startSpan('DB Transaction'); // Transaction内でSpanを作成
const res = await db.query.transaction(async (tx) => {
let insertedUsers: InsertUserTable[] = [];
let i = 0;
for (const user of users) {
i++;
const s = tracer.startSpan(`Insert User Count: ${i}`); // 1roopごとにSpanを作成
const u = await tx.insert(UserTableSchema).values(user).returning().execute();
if (u.length < 1) {
await tx.rollback();
return null;
}
insertedUsers.push(u[0]);
s.end(); // 1roopごとのSpanを終了
}
return insertedUsers;
});
dbSpan.end(); // Transaction内のSpanを終了
//...
return c.json(res, 200);
});
});
上記のようにビジネスロジックに計装を行い、リクエストごとのトレースを取ることで、APIのパフォーマンスを可視化することができます。
下記はExporterをBaselime[2]に設定し、Cloudflare Workers上でのTraceを可視化する例です。
応用編
ここからはサードパーティライブラリとの組み合わせや、より実践的な開発Tipsについて紹介します。
様々なRequest/Responseの扱い
ファイルアップロード
ファイルアップロードといえばmultipart/form-data
ですが、HonoではHonoRequestにparseBody
が実装されており、これにより簡単にmultipart/form-data
を扱うことができます。
const body = await c.req.parseBody({ all: true })
@hono/zod-openapi
との組み合わせで、複数ファイルアップロードのスキーマを定義し、型安全にファイルを扱うことができます。
const fileUploadRequestBodySchema = z.object({
files: z
.preprocess(
(input) => {
if (!Array.isArray(input)) {
return [input]
}
return input
},
z.array(z.custom<File>((v) => v instanceof File))
)
.openapi({
type: 'array',
items: {
type: 'string',
format: 'binary',
description: 'File',
},
}),
})
const reqValidationResult = fileUploadRequestBodySchema.safeParse(
await c.req.parseBody({ all: true })
)
//reqValidationResult.data.files: File[]
またBody Limit Middlewareと組み合わせることで、アップロードされたファイルのサイズに対して制限をかけることも可能です。
ストリーミング
Honoではストリーミングを扱いやすくするヘルパーが用意されています。
最近ではOpenAIのAPI等がストリーミングをサポートしているため、それと組み合わせてリアルタイムに処理を行う例が増えています。
app.post("/chat", async (c) => {
return streamSSE(c, async (stream) => {
stream.onAbort(() => {
stream.close()
})
const chatStream = openai.beta.chat.completions.stream({
model: "gpt-3.5-turbo",
messages: [{ role: "user", content: "hoge" }],
stream: true,
});
for await (const message of chatStream) {
stream.writeSSE({ // Server-Sent Events
data: JSON.stringify({
message: message.choices[0].message.content,
//...
})
})
}
stream.close();
});
});
上記のように、ストリーミングを扱いやすくでき、かつStreamingAPIにはonAbort
やpipe
が実装されているため、ストリーミングの中断やReadableStreamの繋ぎ合わせ等も実装が可能です。
公式でもVercelのSDKと組み合わせた例が紹介されています。
WebSocket
HonoではWebSocketを扱いやすくするためのヘルパーも用意されています。
特にRPCモードを使用した場合に、Server/Client間で非常に簡単にSocketオブジェクトを扱うことが可能になります。
// server.ts
const wsApp = app.get(
'/ws',
upgradeWebSocket((c) => {
//...
})
)
export type WebSocketApp = typeof wsApp
// client.ts
const client = hc<WebSocketApp>('http://localhost:8787')
const socket = client.ws.$ws()
セキュリティについて
Honoではセキュリティに関するミドルウェアやヘルパーが用意されています。
Secure Headers Middleware
基本的なセキュリティヘッダを設定するためのミドルウェアです。strictTransportSecurity
などHTTPSを強制するヘッダや、XSSのフィルタリングを行うxXssProtection
等のヘッダを設定することができます。
const app = new Hono()
app.use(
'*',
secureHeaders({
strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload',
xXssProtection: '1',
})
)
nonce属性を使用したCSPの設定も可能です。
import { secureHeaders, NONCE } from 'hono/secure-headers'
import type { SecureHeadersVariables } from 'hono/secure-headers'
// 変数の型を指定して`c.get('secureHeadersNonce')`を推論:
type Variables = SecureHeadersVariables
const app = new Hono<{ Variables: Variables }>()
// 事前定義されたnonce値を`scriptSrc`に設定:
app.get(
'*',
secureHeaders({
contentSecurityPolicy: {
scriptSrc: [NONCE, 'https://allowed1.example.com'],
},
})
)
// `c.get('secureHeadersNonce')`から値を取得:
app.get('/', (c) => {
return c.html(
<html>
<body>
{/** contents */}
<script src='/js/client.js' nonce={c.get('secureHeadersNonce')} />
</body>
</html>
)
})
nonce値はContext経由で取得できるようになっています。
内部実装は下記のように、secureHeadersNonce
というキーでnonce値をContextに保存しています。
const generateNonce = () => {
const buffer = new Uint8Array(16)
crypto.getRandomValues(buffer)
return encodeBase64(buffer)
}
export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => {
const nonce =
ctx.get('secureHeadersNonce') ||
(() => {
const newNonce = generateNonce()
ctx.set('secureHeadersNonce', newNonce)
return newNonce
})()
return `'nonce-${nonce}'`
}
Authentication Middleware
HonoではBasic, Bearer, JWTなどの認証を簡単に実装するためのミドルウェアが用意されています。
後述しますがOpenAPIHonoと組み合わせてSwagger UIを表示することができるサードパーティライブラリがあります。特定のページのみBasicAuthをつける等の柔軟な認証設定が可能です。
import { swaggerUI } from '@hono/swagger-ui'
import { basicAuth } from 'hono/basic-auth'
//...
app.use('/swagger-ui', basicAuth({
username: 'xxx',
password: 'yyy',
}))
app.use('/doc', basicAuth({
username: 'xxx',
password: 'yyy',
}))
app.doc('/doc', {
openapi: '3.1.0',
info: {
version: '1.0.0',
title: 'nozomi-chat-api',
description: 'Nozomi API',
},
})
app.get('/swagger-ui', swaggerUI({ url: '/doc' }))
JWT
JWTはヘルパーで基本的な操作(デコード、署名、検証)を行えるようになっており、一般的な対称トークンであれば検証も行うことができます。
import { verify } from 'hono/jwt'
const tokenToVerify = 'token'
const secretKey = 'mySecretKey'
const decodedPayload = await verify(tokenToVerify, secretKey)
HMACやRSA等、基本的なアルゴリズムはサポートしていますが、Auth0やClerk等で使用される非対称トークンの公開鍵検証は実装されていないため、jose等のライブラリを組み合わせる必要があります。
Hono Proxy
HonoのRouterは正規表現やワイルドカードに対応しているため、特定のパス以下すべてに対してリクエストをプロキシする等の処理を簡単に書くことができます。
import { Hono } from 'hono'
const app = new Hono()
app.get('/posts/:filename{.+.png$}', (c) => {
const referer = c.req.header('Referer')
if (referer && !/^https:\/\/example.com/.test(referer)) {
return c.text('Forbidden', 403)
}
return fetch(c.req.url)
})
app.get('*', (c) => {
return fetch(c.req.url)
})
export default app
差し込みのミドルウェアを使用してETagやキャッシュを挟むことも容易です。
Cloudflare Workersを使用したプロキシパターンとして紹介されています。
Request ID Middleware
Hono v4.5.0からRequest IDを自動で生成しContextで扱える便利ミドルウェアが追加されました。
import { Hono } from 'hono'
import { requestId } from 'hono/request-id'
const app = new Hono()
app.use('*', requestId())
app.get('/', (c) => {
return c.text(`Your request id is ${c.get('requestId')}`)
})
このrequestIDですが、IDのgenerator
としてcrypto.randomUUID()
を使用しています。大体どのランタイムでも動くと思いますが、自分でカスタマイズも可能になっています。
app.use('*', requestId({
generator: () => {
return 'custom-request-id'
}
}))
さらにheaderName
を指定してあげることで、リクエストヘッダーからrequestIDを取得することも可能です。(デフォルトはX-Request-ID
)
app.use('*', requestId({
headerName: 'X-Custom-Request-ID'
}))
この機能により、フロントエンドからバックエンドまで透過的にRequest IDを扱うことも可能になります。
IP Restrict Middleware
Hono v4.5.0からIP制限をかけるためのミドルウェアが追加されました。
このミドルウェアによりIPv4, IPv6のアドレスを指定してアクセス制限をかけることができます。
allow/deny, CIDR形式の指定も可能です。
import { Hono } from 'hono'
import { getConnInfo } from 'hono/bun'
import { ipRestriction } from 'hono/ip-restriction'
const app = new Hono()
app.use(
'*',
ipRestriction(getConnInfo, {
denyList: [],
allowList: ['127.0.0.1', '::1']
})
)
上記で使用しているgetConnInfo
はContext
からIPアドレスを取得するためのhelperです。getConnInfo
自体はadaptorとして各ランタイム、ホスティング先ごとに実装されています。
Cloudflare Pages Middleware
Hono v4.5.0より、Cloudflare Pages Functionのミドルウェアとしてそのまま扱えるアダプターが追加されました。
import { handleMiddleware } from 'hono/cloudflare-pages'
import { basicAuth } from 'hono/basic-auth'
export const onRequest = handleMiddleware(
basicAuth({
username: 'hono',
password: 'acoolproject'
})
)
Pages FunctionとしてHonoを使用する際には、このアダプターを使用することで簡単にミドルウェアを適用することができます。
Service Worker Adapter
Hono v4.5.0より、Service Worker上でHonoが使用できるアダプターが追加されました。
ブラウザ上でのバックグラウンド処理や、オフライン対応のためにService Workerを使用する際に、Honoの統合が容易になっています。
import { Hono } from 'hono'
import { handle } from 'hono/service-worker'
const app = new Hono().basePath('/sw')
app.get('/', (c) => c.text('Hello World'))
self.addEventListener('fetch', handle(app))
このファイルをService Workerとしてmain側で登録することで、Honoアプリケーションは/sw.tsへのアクセスをinterceptし、Honoのルーターを使用してリクエストを処理します。
executionCtx
HonoのContextにはexecutionCtxというオブジェクトのプロパティがあります。
Cloudflare Workers等のServerless環境では、レスポンスが返った後に処理を行うことができません。
そのため、重い処理はキューイングしたり、タイムアウトを気にしつつ同期的に処理を行う必要があります。
このような状況下でexecutionCtx.waitUntil
を使用することで、バックグラウンドで非同期処理を行うことが可能です。
// ExecutionContext object
app.get('/foo', async (c) => {
c.executionCtx.waitUntil(
c.env.KV.put(key, data)
)
...
})
具体的なユースケースとしてはDBのコネクションの解放や、ログやメトリクスのemit/flush処理などが挙げられます。
const PostUserApi = (app: App) =>
app.openapi(postUserRoute, async (c: AppContext) => {
const { db } = c.get("services");
// ...
const res = await db.query.transaction(async (tx) => {
const u = await tx.insert(UserTableSchema).values(user).returning().execute();
if (u.length < 1) {
await tx.rollback();
return null;
}
return u.at(0);
});
if (!res) {
throw new CustomApiError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to insert user",
});
}
c.executionCtx.waitUntil(db.client.end()); // connectionの解放
return c.json(res, 200);
});
下記記事ではwaitUntil
とCache
を使用して、ISRを再現する例が紹介されており非常に参考になります。
Hono Zod OpenAPIで実現するスキーマ駆動開発
実際にAPIを開発する際はOpenAPIをベースとしたスキーマ駆動開発を行う場合が多いと思います。
Honoではサードパーティライブラリであるzod-openapi
とswagger-ui
を使用することで、スキーマ駆動開発を円滑に行えるようになっています。
下記のようにOpenAPIHono
を定義し、routeを登録します。
import { OpenAPIHono } from '@hono/zod-openapi'
const app = new OpenAPIHono()
app.openapi(route, (c) => {
const { id } = c.req.valid('param')
return c.json({
id,
age: 20,
name: 'Ultra-man',
})
})
オリジナルのHonoインスタンスとの違いはcreateRoute
を元にhttpメソッドおよびpath等を指定する点です。
import { createRoute } from '@hono/zod-openapi'
const route = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
'application/json': {
schema: UserSchema,
},
},
description: 'Retrieve the user',
},
},
})
上記のOpenAPIHonoインスタンスに対してopenapi
メソッドを通じてRouterを登録すると、ZodのSchemaから自動的にOpenAPI Documentationを作成してくれるようになります。
また、swagger-ui
を使用することで、自動生成されたOpenAPI DocumentationをSwagger UIとして表示することができます。
さて、ここでミドルウェアの設計で紹介したfactoryパターンを使用し、OpenAPIHonoインスタンスを作成してみます。
const newApp = () => {
const app = new OpenAPIHono<HonoEnv>({
defaultHook: handleZodError,
});
app.use(prettyJSON());
app.onError(handleError);
app.use('/swagger-ui')
app.use('/doc')
app.doc('/doc', {
openapi: '3.1.0',
info: {
version: '1.0.0',
title: 'api',
description: 'API',
},
})
app.get('/swagger-ui', swaggerUI({ url: '/doc' }))
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
bearerFormat: "root key",
type: "http",
scheme: "bearer",
});
return app;
}
OpenAPIHonoには、Honoに標準で用意されているメソッドはもちろん、defaultHook
やopenAPIRegistry
のメソッドがあります。
defaultHook
defaultHook
はZodのエラーハンドリングを行うためのフックです。
defaultHookはOpenAPIHonoクラスのインスタンスにおいて、バリデーションが失敗した場合や処理後に実行されるフックとして定義されます。
内部的にはopenapi
メソッドで登録されたRouteにアクセスが来た際にzValidatorを通じてバリデーションを行い、エラーが発生した場合にdefaultHook
内のresultがfalseになります。
そのため、ルートのインスタンスでdefaultHook
を使用することで、バリデーションエラーに対するエラーハンドリングを一括で行うことができます。
下記のようなhandlerを定義し、zodのエラーメッセージを返すことで、通常のHTTPException
と同様のスキーマでエラーハンドリングを行うことが可能になります。
const handleZodError = (
result:
| {
success: true;
data: any;
}
| {
success: false;
error: ZodError;
},
c: Context<HonoEnv>,
) => {
if (!result.success) {
return c.json<ErrorResponse, StatusCode>(
{
error: {
code: "BAD_REQUEST",
message: parseZodErrorMessage(result.error),
requestId: c.get("requestId"),
},
},
{ status: 400 },
);
}
}
また、route定義時にErrorSchemaも定義することが可能なため、汎用的なエラースキーマを定義しておくと使い手のエラーハンドリングが楽になります。
const errorSchemaFactory = (code: z.ZodEnum<any>) => {
return z.object({
error: z.object({
code: code.openapi({
description: "error code.",
example: code._def.values.at(0),
}),
message: z
.string()
.openapi({ description: "explanation" }),
requestId: z.string().openapi({
description: "requestId",
example: "req_1234",
}),
}),
});
}
const errorResponses = {
400: {
description:
"The server cannot or will not process the request due to something that is perceived to be a client error.",
content: {
"application/json": {
schema: errorSchemaFactory(z.enum(["BAD_REQUEST"])).openapi("ErrBadRequest"),
},
},
},
401: {
description: `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". ...`,
content: {
"application/json": {
schema: errorSchemaFactory(z.enum(["UNAUTHORIZED"])).openapi("ErrUnauthorized"),
},
},
},
403: {
description:
"The client does not have access rights to the content; ...",
content: {
"application/json": {
schema: errorSchemaFactory(z.enum(["FORBIDDEN"])).openapi("ErrForbidden"),
},
},
},
// ...
}
RouteにErrorSchemaを定義
const postUserRoute = createRoute({
tags: ["user"],
operationId: "userKey",
method: "post" as const,
path: "/users",
security: [{ bearerAuth: [] }],
request: {
body: {
required: true,
content: {
"application/json": {
schema: UserPostBodySchema,
},
},
},
},
responses: {
200: {
description: "The configuration for an api",
content: {
"application/json": {
schema: UserResponseSchema,
},
},
},
...errorResponses, // 定義したErrorSchemaを使用
},
});
上記を行うと、Swagger UI上でエラーハンドリングが行われた際のスキーマが表示されるようになります。
openAPIRegistry
OpenAPIHonoにはopenAPIRegistry
というプロパティがあり、OpenAPIのスキーマを登録することができます。
この機能により、セキュリティスキームを登録し、Routeから使用することが可能になります。
// securitySchemes
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
bearerFormat: "root key",
type: "http",
scheme: "bearer",
});
// route
const postUserRoute = createRoute({
// ...
security: [{ bearerAuth: [] }],
})
Honoの活用事例
この内容はHono Conference 2024にて発表した内容をもとにしています。
AI ShiftではHonoを使用してAI WorkerというB2BマルチテナントSaaSアプリケーションの開発を行っています。
設計や使用技術の詳細についてはこちらのブログを参照ください。
Tech Stack
AI Workerはお客様環境や自社環境のk8s環境にデプロイするために、各サービスをコンテナ化して扱っています。
その中で、フロントエンドから使用するAPIにHonoを採用しています。
ユースケース
AI WorkerのキーワードはAI
とマルチテナントSaaS
です。
AI
AI Workerは名前の通りAIを活用したサービスを提供しています。
AIと一括りにすると領域が広すぎるので、ここではLLMの利用について紹介します。
- LLM
LLMというと昨今ではGPTやClaudeの利用が挙げられます。
特にOpenAI社が提供するGPTのAPIでは、LLMの回答生成時に**Stream(Server Sent Event)**による回答の生成方法をサポートしています。
これにより、インタラクティブな対話型UIを実現することが可能になります。
前述したSSEのヘルパーを使用することで、Hono上で簡単にストリーミングを実装することができます。
- RAG
このLLMによるテキスト生成を活用するために、弊社ではRAGを取り入れた社内文書検索の仕組みを取り入れています。
RAGはユーザInput(Query) をもとにVector Storeから関連性に基づいて文書を検索し、その結果をPromptに埋め込み回答LLMで生成するという仕組みです。(とてもざっくり)
このような仕組みを活用するためには、あらかじめ検索に使用するためのドキュメントを何らかの方法でVector Storeに登録しておく必要があります。
ここでHonoのファイルアップロードが活躍します。
B2BマルチテナントSaaS
AI WorkerはB2BマルチテナントSaaSアプリケーションです。
お客様ごとに異なる設定やセキュリティポリシーを持つため、データを適切に分離する必要があります。
テナントごとのDB特定のために、TenantID
を使用してテナントごとのデータを特定しています。
HonoのContextを使用して、リクエストごとにTenantID
を保持することで、テナントごとのデータを取得することができます。
const getUser = (c: AppContext): Result<User> => {
const p: Payload = c.get('jwtPayload')
if (!p.org_id) {
return Result.fail(new AppError(StatusCode.forbidden, 'Please select an organization'))
}
c.set('sessionUser', {
userId: p.sub,
tenantId: p.org_id,
})
}
const middleware = (): MiddlewareHandler<HonoEnv> => {
return async (c, next) => {
const res = await getUser(c)
if (!res.isSuccess) {
return c.json({ message: res.getError().message }, res.getError().code)
}
const { logger } = c.get('services')
logger.info({
message: '[Request Started]',
method: c.req.method,
url: c.req.url,
userInfo: c.get('sessionUser'),
})
return await next()
}
}
任意のIdPから取得したJWTのペイロードからtenantIdを取得し、Contextにセットし伝播させることでテナントデータを扱いやすくします。
c.set('sessionUser', {
userId: p.sub,
tenantId: p.org_id,
})
この際にLoggerを同時に仕込み、アクセスログとして残すことも可能です。(どのTenantのユーザがどのリクエストを投げたかを残す)
const { logger } = c.get('services')
logger.info({
message: '[Request Started]',
method: c.req.method,
url: c.req.url,
userInfo: c.get('sessionUser'),
})
- Security
最後に簡単なセキュリティ対策の例を紹介します。
セキュリティについてで紹介したSecure Headers Middleware
の使用や、JWTの適切な検証はもちろんのこと、アプリケーション内での人が起こすミスを防ぐことも必要です。
例えば弊社ではアプリケーションの構築にClean Architectureを採用しており、各レイヤー間のEntityの詰め替え処理を行う際にZodによるバリデーションを行っています。
これは例ですが、仮にUser
というEntityにpassword
というフィールドが含まれている場合、そのままDBに保存したり、レスポンスに含めることはセキュリティ上好ましくありません。
そこで、Zodによるparseを活用し、password
フィールドを含めないようにすることが可能です。
まとめ
Honoの一連の機能を使いこなすことで、開発効率を向上させながら堅牢なAPIを開発することができます。
ここでは紹介しきれていないHono RPCやHonoX等、さまざまな機能があるので公式ドキュメントを参照しながら、ぜひHonoを使って開発を進めてみてください。
参考
-
OpenTelemetryはログ、トレース、メトリクスを統一的に扱うためのライブラリです。
https://opentelemetry.io/ ↩︎
Discussion