コンテンツへスキップ

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