Alternative Architecture DOJO

オルターブースのクラウドネイティブ特化型ブログです。

ASP.NET Core Web APIで application/problem+json のレスポンスを扱う

こんにちは、MLBお兄さんこと松村です。
大谷翔平選手が 50-50 (50本塁打-50盗塁) を達成しましたね。と思っていたら 55-55 の可能性がもう目前まで迫っているという、素晴らしすぎるシーズンになっていますね。


.NET 7 がリリースされてから、ASP.NET Core Web API のドキュメントやサンプルコードに、このコードが記載されるようになっていたことに気付きました。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();

var app = builder.Build();

app.MapGet("/", () => "Hello World");

app.Run();

builder.Services.AddProblemDetails(); で使用されている IServiceCollection.AddProblemDetails() について調べてみました。

IServiceCollection.AddProblemDetails()

IServiceCollection.AddProblemDetails() はミドルウェアとして利用することができます。
このミドルウェアは .NET 7 から追加されていたようです。ドキュメントはこちらです。

learn.microsoft.com

learn.microsoft.com

ドキュメントでは以下のように言及されています。

問題の詳細は、HTTP API エラーを記述する唯一の応答形式ではありませんが、一般的に HTTP API のエラーを報告するために使用されます。

問題の詳細サービスは、IProblemDetailsService インターフェイスを実装し、これにより、ASP.NET Core での問題の詳細の作成がサポートされます。 IServiceCollection の AddProblemDetails(IServiceCollection) 拡張メソッドは、既定の IProblemDetailsService 実装を登録します。

この「問題の詳細」というのは、RFC 7807 の「Problem Details for HTTP APIs」を指しています。
この RFC は Web API におけるエラー情報を表現するための、統一的なレスポンス形式を説明しています。
www.rfc-editor.org

ざっと RFC 7807 を読みましたが、下記の構成で問題の詳細を表現するようです。

  • ヘッダー
    • Content-Typeapplication/problem+json または application/problem+xml とする
  • ボディ
    • "type" : 問題の種類に該当する RFC の URI ※必須
    • "title" : 問題の要約 (文字列)
    • "status" : HTTP ステータスコード (数値)
    • "detail" : 問題の詳細
    • "instance" : 問題の特定の発生を識別する URI
    • 上記以外の項目も追加可能 (拡張)

エラーレスポンスを確認してみる

それでは実際に RFC 7807 の形式のエラーレスポンスを確認してみます。
サンプルとしてエンドポイントを持たない Minimal API を使用します。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseStatusCodePages();
app.Run();

存在しないエンドポイントに HTTP GET リクエストを送信し、レスポンスを確認します。

curl -i http://localhost:5236/users
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Date: Sun, 22 Sep 2024 12:28:43 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404}

"type", "title", "status" が既定で含まれることが確認できました。
IServiceCollection.AddProblemDetails() で登録されたミドルウェアによって、HTTP 404 のレスポンスが生成されています。

レスポンスの項目を拡張する

IServiceCollection.AddProblemDetails() のオプションを構成することで、問題の詳細のレスポンスに任意の項目を追加することができます。
下記の例では、現在時刻、URL のパス、URL のクエリ文字列を含めるようにしています。

builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions.Add("now", DateTimeOffset.Now);
        ctx.ProblemDetails.Extensions.Add("path", ctx.HttpContext.Request.Path.ToString());
        ctx.ProblemDetails.Extensions.Add("query", ctx.HttpContext.Request.Query);
    });
curl -i http://localhost:5236/users/1?name=yuta
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Date: Sun, 22 Sep 2024 13:08:56 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"now":"2024-09-22T22:08:57.2497121+09:00","path":"/users/1","query":[{"key":"name","value":["yuta"]}]}

例外発生時のエラーレスポンスを構成する

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseStatusCodePages();

app.MapGet("/exception", () =>
{
    throw new InvalidOperationException("This is an exception");
});
app.Run();
curl -i http://localhost:5236/exception
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Date: Sun, 22 Sep 2024 12:56:43 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"System.InvalidOperationException","status":500,"detail":"This is an exception","exception":{"details":"System.InvalidOperationException: This is an exception\r\n   at Program.<>c.<<Main>$>b__0_0() in D:\\source\\tmp\\dotnet\\web\\ProblemDetailsApp\\Net8ProblemDetailsApp\\Program.cs:line 84\r\n   at lambda_method1(Closure, Object, HttpContext)\r\n   at Microsoft.AspNetCore.Diagnostics.StatusCodePagesMiddleware.Invoke(HttpContext context)\r\n   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)","headers":{"Accept":["*/*"],"Host":["localhost:5236"],"User-Agent":["curl/8.8.0"]},"path":"/exception","endpoint":"HTTP: GET /exception","routeValues":{}}}

ヘッダーやボディ構成は同様ですが、例外メッセージが "detail" に、例外情報が "exception" に含まれるようになりました。
開発環境 (ASPNETCORE_ENVIRONMENT=Development) で動かしているため、開発者例外ページの内容が含まれています。

なお、本番環境 (ASPNETCORE_ENVIRONMENT=Production) の場合はレスポンスボディが無い状態となります。

curl -i http://localhost:5236/exception
HTTP/1.1 500 Internal Server Error
Content-Length: 0
Date: Sun, 22 Sep 2024 13:45:39 GMT
Server: Kestrel

そのため例外ハンドラーを使用して、HTTP 500 のレスポンスを構成します。

app.UseExceptionHandler(exceptionHandlerApp =>
    exceptionHandlerApp.Run(async context =>
        await Results.Problem().ExecuteAsync(context)));

app.MapGet("/exception", () =>
{
    throw new InvalidOperationException("This is an exception");
});

app.Run();
curl -i http://localhost:5236/exception
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Date: Sun, 22 Sep 2024 13:18:22 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: -1
Pragma: no-cache
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500}

Web API のエラーハンドリングはミドルウェアで自作することが多いと思いますが、RFC に則ったフォーマットにしておけばクライアント側も振る舞いを合わせやすいので、積極的に使っていきたい機能です。


サービス一覧 www.alterbooth.com cloudpointer.tech www.alterbooth.com