OpenAPIレスポンスバリデーションでの500エラー
始めに
typescriptを初めて触れていく中でAPIの開発も初めてでわからないことだらけの状態ですが
API開発のチュートリアルを進めていく中で 解決に特に時間がかかった今回の問題を備忘録として
残したいと思います。
この記事では、OpenAPIによるレスポンスバリデーションを導入している開発環境で、
ユニットテストは通るのに、結合テスト(APIテスト(controller経由))では突然500エラーが返ってくるという現象に直面した話と、その背景・原因・対処方法を備忘録として整理します。
背景
OpenAPIバリデーションを使うと、以下のように リクエスト・レスポンスが仕様と合っているか自動チェックできます。
OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateResponses: true, //trueにする
validateRequests: true, //trueにする
});
特に validateResponses: true を有効にしていると、レスポンスの形式がOpenAPIの仕様と少しでもズレているだけで、500 Internal Server Error に変換されてしまうのが特徴です。
前提
今回のプロジェクトでは オニオンアーキテクチャ(Onion Architecture) を採用しています。
その構造上、以下のようなレイヤーに分かれています:
レイヤー | 説明 |
---|---|
Controller(プレゼンテーション層) | クライアントからのHTTPリクエストを受け取り、Serviceに処理を委譲する |
Service(ユースケース層) | ビジネスロジックを実行し、DTOとして結果を返す |
Repository(永続化層) | DBなどとのやりとりを行う |
Entity/Domain(ドメイン層) | 業務ルールを表現するモデル・ロジック |
つまりリクエストがあった場合、各層に以下のような順番で処理が走っていきます。
Presentation(Controller)
↓
Application(Service / UseCase)
↓
Domain(Entity / Repository Interface)
↓
Infrastructure(DBなど)
📌 Controller は「入出力の整形とAPI仕様に沿ったレスポンスの組み立て」が主な役割です。
このとき、OpenAPIに準拠した構造でレスポンスを返さなければ、バリデーションによって失敗します。
現象 コントローラーテストは500エラー
例えば以下のようなテストコードがあったとします。
以下のようなAPIテストを実行すると、予期せぬ500エラーが返ってきました。
const res = await request(app).get('/api/items/999');
expect(res.status).toBe(404); // ❌ Expected: 404, Received: 500
OpenAPIレスポンスバリデーションに引っかかって 500 が返る。
疑問 なぜ service(ユースケース層) のテストは通るのか?
Service(ユースケース)のテスト
- Controller層を通らず、直接ユースケース(Service)を呼ぶのでHTTPレスポンスを生成しない
- OpenAPIバリデーションが走らない
- DTOの構造が仕様とズレていても気づけない
- たとえば null や undefined の値でもテストは通る
Controller(API)のテスト
- OpenAPI定義とレスポンスが比較される
- 定義と違う値(例:null禁止の項目にnull、未定義のstatusなど)を返すと500エラーになる
どのような処理でエラーが起きたのか
routingの設定は別ファイルで書いていますが、service層で書いてある処理の内容が以下のような内容であったとします。
app.get('/api/items/:id', async (req, res, next) => {
try {
const item = await this._sampleRepository.getById(req.params.id);
if (!item) throw new NotFoundError('Item not found');
res.status(200).json(item);
} catch (e) {
next(e); // → express-openapi-validator に渡る
}
});
このとき、以下のような存在しないIDに対してリクエストがあったとします。
999というIDはデータベースに存在しないのでitem
はnullになり、 throw new NotFoundError()を発生させるので、テストで期待通りに404レスポンスを返すことを期待します。
GET /api/items/999
describe('異常系', () => {
test('存在しないIDを指定した場合、エラーが返される', async () => {
const response = await agent.get(`${endpoint}/999`);
expect(response.status).toBe(404);
});
});
実際に出たエラー
Expected: 404
Received: 500
response should NOT have a body
今回の原因
OpenAPI定義ファイル(例: openapi.yaml)に、404レスポンスの定義が存在しなかったためです。
Validatorは仕様にないレスポンス(= 404)が返されたと判断し、
と、内部的に 500 Internal Server Error を返してしまいます。
修正
以下のようにcomponents
を使って404を明示的に定義します。
paths:
/api/items/{id}:
get:
summary: 商品詳細取得
responses:
'200':
description: 商品が見つかった
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'404':
$ref: '#/components/responses/404-NotFound' # ← 追記
'500':
$ref: '#/components/responses/500-InternalServerError'
日付に関するバリデーションエラー
実際に出たエラー
request/body/deadline must match pattern "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}"
原因
OpenAPI定義の中で、以下のように "pattern" による日付バリデーションが設定されていたため、
「2024-01-01T00:00:00.000Z」のような ISO 8601形式(T区切り) が許可されず、スペース区切りの文字列("YYYY-MM-DD HH:mm:ss")のみ許容されていました。
expired_at:
type: string
pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}' # ← これが厳しすぎる
example: '2021-01-01 00:00:00'
description: 期限日
解決方法
OpenAPI定義を以下のように修正して、ISO形式を許容する format: date-time に変更し、pattern を削除しました。
expired_at:
type: string
format: date-time # ISO形式(T区切り)が通るようになる patternは削除
example: '2021-01-01T00:00:00Z'
description: 期限日
補足 fomat: date-timeとは
format: date-time を使うと、2024-12-31T23:59:59Z"
のような ISO 形式の日時文字列が許容されます。
JavaScriptではISO 形式の文字列をそのまま Date オブジェクトに変換することができます。
結論
OpenAPIのレスポンスバリデーションでは、設計と実装(controller, DTO)のズレがあると、想定外の 500エラー を引き起こします。
結合テストを実施する際に、以下のような点を網羅できているかを確認しないと処理自体は正しくでもバリデーションで失敗することがわかりました。
- service層で throw するエラーに対応する response 定義があるか?
- nullになる可能性のあるプロパティに nullable: true を付けているか?
まだまだわからないことだらけですが、引き続き理解に努めていきたいと思います。
Discussion