コンテンツへスキップ

APIエラーを構造化するRFC 9457ミドルウェアをHono向けに作った

APIエラーを構造化するRFC 9457ミドルウェアをHono向けに作った のアイキャッチ
Contents

はじめに

APIのエラーレスポンスは、書く人によってバラバラになりがちです。

{"error": "not found"}
{"message": "Invalid email", "code": 422}
{"errors": [{"field": "email", "msg": "required"}]}

同じアプリ内でもエンドポイントごとに形式が違う。クライアント側はエラーの種類ごとに分岐を書く羽目になり、フロントエンドの同僚から「エラーのパースどうすればいいの」と聞かれます。

RFC 9457 "Problem Details for HTTP APIs"はまさにこの問題のための標準仕様です。すべてのHTTPエラーを5つのフィールド + 拡張メンバーの統一フォーマットで表現します。

Honoにはこの仕様のミドルウェアがなかったので、hono-problem-detailsとして作りました。

GitHub - paveg/hono-problem-details: RFC 9457 Problem Details middleware for Honogithub.com

TL;DR

  • app.onErrorに一行追加するだけで、全エラーがapplication/problem+json形式になる
  • throwするだけでRFC 9457準拠のレスポンスが返る
  • Zod / Valibot / Standard Schema のバリデーションエラーを自動変換
  • @hono/zod-openapi連携でAPIドキュメントにも反映
  • 型安全なエラーレジストリで、エラー型を事前定義
  • ゼロ外部依存。honoのみがpeer dependency
import { Hono } from 'hono';
import { problemDetailsHandler } from 'hono-problem-details';
const app = new Hono();
app.onError(problemDetailsHandler());

RFC 9457とは

RFC 9457(旧RFC 7807)は、HTTPエラーレスポンスの標準フォーマットです。Content-Typeにapplication/problem+jsonを使います。

{
"type": "https://api.example.com/problems/out-of-credit",
"status": 403,
"title": "You do not have enough credit",
"detail": "Your current balance is 30, but the item costs 50",
"instance": "/account/12345/transactions/abc"
}

標準フィールドは5つ。

フィールド意味必須
type問題の種類を示すURI✓(デフォルトabout:blank
statusHTTPステータスコード
title問題の短い要約
detailこの発生固有の説明
instanceこの発生を識別するURI

さらに拡張メンバー(Extension Members)を自由に追加できます。バリデーションエラーの詳細配列、リトライまでの秒数など、アプリ固有の情報を載せる場所です。

仕様上のポイントとして、拡張メンバーは標準フィールドを上書きできません(Section 3.1)。extensionsstatusを入れても、標準フィールドが常に勝ちます。


作ったのか

HonoのHTTPExceptionは手軽ですが、throw new HTTPException(404)するとプレーンテキストの"Not Found"が返ります。JSON APIなのにContent-Typeがtext/plainでは、クライアント側でパースの分岐が必要になります。

他のフレームワークには既に実装があります。

  • Express では http-errors とエラーハンドラーを組み合わせる
  • Fastify は @fastify/sensible が RFC 7807 に対応
  • NestJS や Spring Boot はフレームワーク組み込み

Honoは軽量さが魅力のフレームワークなので、こうした仕組みは内蔵していません。app.onErrorにフックする形のProblem Detailsミドルウェアが必要でした。

hono-problem-detailsは app.onErrorに渡すだけで、全エラーが構造化される ことを最優先に設計しています。


設計思想

1. app.onError一行で導入できる

既存コードを一切変更せずに導入できることを重視しました。

app.onError(problemDetailsHandler());

これだけで、HTTPExceptionも通常のErrorも、すべてapplication/problem+json形式になります。既存のthrow new HTTPException(404)はそのまま動きます。

ハンドラー内部の処理順序:

  1. ProblemDetailsError → そのまま変換
  2. mapErrorが設定されていれば → カスタムマッピング
  3. HTTPException → ステータスコードとメッセージから変換
  4. その他のError → 500 Internal Server Error

2. 拡張メンバーのspread順序

RFC 9457 Section 3.1に従って、拡張メンバーを先にspreadし、標準フィールドで上書きしています。

const body = { ...sanitizeExtensions(extensions), ...standard };

extensions: { status: "custom" }を渡しても、レスポンスのstatusは常にHTTPステータスコードです。この順序は仕様準拠のために意図的にこうしています。

3. セキュリティ

エラーレスポンスは外部に公開されるので、防御的に作っています。

プロトタイプ汚染防止: 拡張メンバーから__proto__constructorprototypeを除去します。

const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function sanitizeExtensions(extensions) {
if (!extensions) return extensions;
let filtered;
for (const key of Object.keys(extensions)) {
if (DANGEROUS_KEYS.has(key)) {
if (!filtered) filtered = { ...extensions };
delete filtered[key];
}
}
return filtered ?? extensions;
}

危険なキーがなければオブジェクトのコピーは発生しません。通常パスのコストはゼロです。

安全なJSON.stringify: 循環参照やBigIntでJSON.stringifyが飛んでも、500のフォールバックレスポンスを返します。

const FALLBACK_BODY = JSON.stringify({
type: 'about:blank',
status: 500,
title: 'Internal Server Error',
});
function safeStringify(body) {
try {
return { json: JSON.stringify(body), fallback: false };
} catch {
return { json: FALLBACK_BODY, fallback: true };
}
}

フォールバックボディはモジュールロード時に一度だけ生成するので、エラーパスでもアロケーションがありません。

ステータスコードのクランプ: WHATWG Fetch APIのResponseコンストラクタは200-599しか受け付けません。範囲外が来たら500にフォールバックします。


エラーの投げ方

基本: problemDetails()

problemDetails()でエラーオブジェクトを作ってthrowするだけです。

import { problemDetails } from 'hono-problem-details';
app.post('/orders', (c) => {
const existing = await findOrder(id);
if (existing) {
throw problemDetails({
status: 409,
detail: `Order ${id} already exists`,
type: 'https://api.example.com/problems/order-conflict',
instance: `/orders/${id}`,
});
}
// ...
});

typetitleは省略できます。typeのデフォルトはabout:blanktitleはステータスコードから自動導出されます(409 → "Conflict")。

応用: Problem Type Registry

APIのエラー型を事前定義しておくと、型安全に生成できます。

import { createProblemTypeRegistry } from 'hono-problem-details';
const problems = createProblemTypeRegistry({
ORDER_CONFLICT: {
type: 'https://api.example.com/problems/order-conflict',
status: 409,
title: 'Order Conflict',
},
RATE_LIMITED: {
type: 'https://api.example.com/problems/rate-limited',
status: 429,
title: 'Too Many Requests',
},
INSUFFICIENT_BALANCE: {
type: 'https://api.example.com/problems/insufficient-balance',
status: 403,
title: 'Insufficient Balance',
},
});

create()の第一引数は登録済みキーのunion型なので、タイポはコンパイル時に弾かれます。

// ✅ 型安全
throw problems.create('ORDER_CONFLICT', {
detail: `Order ${id} already exists`,
instance: `/orders/${id}`,
});
// ❌ コンパイルエラー
throw problems.create('ORDOR_CONFLICT'); // typo

get()types()でレジストリの中身を取り出せるので、ドキュメント生成にも流用できます。


バリデーター連携

Zod

@hono/zod-validatorのhook引数にzodProblemHook()を渡すだけです。

import { zValidator } from '@hono/zod-validator';
import { zodProblemHook } from 'hono-problem-details/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
age: z.number().positive(),
});
app.post('/users', zValidator('json', schema, zodProblemHook()), (c) => {
const data = c.req.valid('json');
return c.json(data, 201);
});

バリデーション失敗時のレスポンス:

{
"type": "about:blank",
"status": 422,
"title": "Validation Error",
"detail": "Request validation failed",
"errors": [
{ "field": "email", "message": "Invalid email", "code": "invalid_string" },
{
"field": "age",
"message": "Number must be greater than 0",
"code": "too_small"
}
]
}

errors配列が拡張メンバーとしてトップレベルに入ります。titledetailはオプションで変更可能です。

Valibot

使い勝手はZodと同じです。バンドルサイズを気にするなら、Valibotとの組み合わせが有効です。

import { vValidator } from '@hono/valibot-validator';
import { valibotProblemHook } from 'hono-problem-details/valibot';
import * as v from 'valibot';
const schema = v.object({
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.minValue(1)),
});
app.post('/users', vValidator('json', schema, valibotProblemHook()), (c) => {
const data = c.req.valid('json');
return c.json(data, 201);
});

Standard Schema

Standard Schema対応のライブラリ(Zod、Valibot、ArkTypeなど)ならどれでも使えます。

import { sValidator } from '@hono/standard-validator';
import { standardSchemaProblemHook } from 'hono-problem-details/standard-schema';
app.post(
'/users',
sValidator('json', schema, standardSchemaProblemHook()),
(c) => c.json(c.req.valid('json'), 201),
);

バリデーションライブラリを乗り換えてもhookの変更は不要です。


OpenAPI連携

@hono/zod-openapiと組み合わせると、エラーレスポンスのスキーマもOpenAPIドキュメントに反映できます。

import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { problemDetailsHandler } from 'hono-problem-details';
import { problemDetailsResponse, createProblemDetailsSchema } from 'hono-problem-details/openapi';
const app = new OpenAPIHono();
app.onError(problemDetailsHandler());
const route = createRoute({
method: 'get',
path: '/users/{id}',
request: {
params: z.object({ id: z.string() }),
},
responses: {
200: {
content: { 'application/json': { schema: UserSchema } },
description: 'User found',
},
404: problemDetailsResponse(404),
422: problemDetailsResponse(422, 'Validation Error'),
},
});

problemDetailsResponse()はステータスコードからdescriptionを自動導出します。拡張メンバーを含むスキーマも定義できます。

const ValidationErrorSchema = createProblemDetailsSchema(
z.object({
errors: z.array(
z.object({ field: z.string(), message: z.string() }),
),
}),
);
// responses内で使用
422: problemDetailsResponse(422, 'Validation Error', ValidationErrorSchema),

ScalarなどのAPIドキュメントUIで、拡張メンバーのスキーマまで正確に表示されます。


ローカライズ

localizeコールバックで、リクエストに応じてtitledetailを翻訳できます。

const translations = {
ja: { 'Not Found': '見つかりません', 'Bad Request': '不正なリクエスト' },
// ...
};
app.onError(
problemDetailsHandler({
localize: (pd, c) => {
const lang = c.req.header('Accept-Language')?.split(',')[0];
const dict = translations[lang];
if (!dict) return pd;
return {
...pd,
title: dict[pd.title] ?? pd.title,
};
},
}),
);

コールバックには組み立て済みのProblemDetailsとHonoのContextが渡されます。Accept-Language以外にも、認証情報やURLパスで分岐できます。


ハンドラーオプション

problemDetailsHandler({
// type URIのプレフィックス。設定するとステータスコードベースのsuffixが付く
// 例: "https://api.example.com/problems" + "/not-found"
typePrefix: 'https://api.example.com/problems',
// デフォルトのtype URI(デフォルト: "about:blank")
defaultType: 'about:blank',
// スタックトレースをdetailに含める(開発環境用)
includeStack: process.env.NODE_ENV === 'development',
// カスタムエラーのマッピング
mapError: (error) => {
if (error instanceof DatabaseError) {
return { status: 503, title: 'Service Unavailable', detail: 'Database is down' };
}
return undefined; // デフォルト処理にフォールバック
},
// ローカライズ
localize: (pd, c) => pd,
});

typePrefixを設定すると、ステータスコードからslugを自動生成します。404 → not-found、422 → unprocessable-contentのように、RFC 9110のフレーズをkebab-caseに変換したものです。

mapErrorは既存のカスタムエラークラスを段階的にProblem Detailsへ移行するときに重宝します。undefinedを返せばデフォルトのHTTPException/Error処理にフォールバックするので、一度に全部書き換える必要はありません。


エコシステム

hono-problem-detailsは、以下のHonoミドルウェアでoptional peer dependencyとして使われています。

  • hono-idempotency — 冪等性キーミドルウェア。エラーをProblem Details形式で返す
  • hono-webhook-verify — Webhook署名検証ミドルウェア。署名エラーをProblem Details形式で返す

どちらもhono-problem-detailsなしで動作します。インストールされていれば自動的にProblem Details形式に切り替わる仕組みです。


始め方

Terminal window
pnpm add hono-problem-details

最小構成:

import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { problemDetailsHandler } from 'hono-problem-details';
const app = new Hono();
app.onError(problemDetailsHandler());
app.get('/users/:id', (c) => {
const user = findUser(c.req.param('id'));
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json(user);
});

Zodバリデーション付き:

Terminal window
pnpm add hono-problem-details @hono/zod-validator zod
import { zValidator } from '@hono/zod-validator';
import { zodProblemHook } from 'hono-problem-details/zod';
app.post(
'/users',
zValidator('json', CreateUserSchema, zodProblemHook()),
(c) => {
const data = c.req.valid('json');
return c.json(createUser(data), 201);
},
);

まとめ

hono-problem-detailsは、HonoのエラーレスポンスをRFC 9457で統一するミドルウェアです。

  • 一行で導入できる。app.onError(problemDetailsHandler()) で全エラーが構造化される
  • RFC 9457 準拠で、5つの標準フィールドと拡張メンバーをサポート
  • バリデーター統合として Zod / Valibot / Standard Schema / OpenAPI に対応
  • 型安全性も担保され、Problem Type Registry でタイポをコンパイル時に検出
  • ゼロ外部依存。peer dependency は hono だけ
  • 防御的な実装として、プロトタイプ汚染防止・安全なシリアライズ・ステータスコードクランプを備える
  • テストカバレッジは100%

GitHub: https://github.com/paveg/hono-problem-details

npm: https://www.npmjs.com/package/hono-problem-details

Issue/PRは歓迎です。気になった点があればお気軽にどうぞ。

X Facebook B! Hatena