銀の光と碧い空

クラウドなインフラとC#なアプリ開発の狭間にいるエンジニアの日々

ASP.NET Core 3 Web APIで例外をスローして、指定したステータスのレスポンスを返す

ASP.NET Core Web APIでアプリケーションロジック内では例外を投げておいて、共通処理として例外をキャッチして例外に応じたステータスコードとメッセージのレスポンスを返したい場合があるかと思います。ASP.NET Framework やCore 2.2まであったHttpResponseExceptionを使うようなユースケースです。

docs.microsoft.com

ASP.NET Core 3ではなくなっていますが、自前で作ることでより自分のユースケースにあった処理が作れることが次のドキュメントに記載されています。

docs.microsoft.com

今回これをすこしアレンジした使い方をしたので紹介してみます。

まず、この例外をスローしたときにどのような共通処理をしたいかを決めるためにActionFilterの実装であるHttpResponseExceptionFilterから説明します。OnActionExecutedメソッド内に処理を記述します。WebAPI(に限らないが)で例外がスローされていた場合、context.Exceptionにその例外オブジェクトが格納されます。この例外がHttpResponseExceptionFilter以外の場合は想定しない例外スローなので適切にログを出力します*1HttpResponseExceptionFilterの場合はHttpResponseException例外オブジェクトに格納されている情報をもとにレスポンスを返します。

using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MyApp
{
    public class HttpResponseExceptionFilter : IActionFilter, IOrderedFilter
    {
        public int Order { get; set; } = int.MaxValue - 10;

        public void OnActionExecuting(ActionExecutingContext context) { }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            if (context.Exception is HttpResponseException exception)
            {
                context.Result = new ObjectResult(new 
                {
                    is_error = true,
                    message = exception.Message
                })
                {
                    StatusCode = exception.Status,
                };
                context.ExceptionHandled = true;
            }
            else if (context.Exception != null)
            {
                //TODO HttpResponseException以外の例外がスローされたので適切にログの処理などを行う
                Console.WriteLine("Unhandled exception!!!");
                Console.WriteLine(context.Exception);
            }
        }
    }
}

今回は次のようなJSONのメッセージで指定してHTTPステータスで返すことを想定しています。そこで、HttpResponseExceptionにステータスコードを表すプロパティを設定し、メッセージはException.Messageプロパティを利用することにしました。

{
  "is_error": true
  "message": "<例外メッセージ>"
}

そこで、HttpResponseExceptionStatusプロパティを持たせています。あとは使いやすいようにいくつかコンストラクタを作成しておきます。

using System;

namespace MyApp
{
    public class HttpResponseException : Exception
    {
        public int Status { get; set; }

        public HttpResponseException(int status, string message, Exception innerException)
         : base(message, innerException)
        {
            Status = status;
        }

        public HttpResponseException(int status, Exception innerException)
         : base(innerException.Message, innerException)
        {
            Status = status;
        }

        public HttpResponseException(int status, string message)
         : base(message)
        {
            Status = status;
        }
    }
}

あとはStartupクラスのConfigureServicesメソッド内でHttpResponseExceptionFilterを有効にします。

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers(options =>
                options.Filters.Add(new HttpResponseExceptionFilter()));
            //...
        }

あとは実際にWebAPIの処理の中でこのように使います。

UserModel user;
try
{
    user = await connection.QueryFirstOrDefaultAsync<UserModel>(
        "SELECT * FROM `users` WHERE `id` = @id",
        new { id = userID });
}
catch (Exception e)
{
    if (txn != null) await txn.RollbackAsync();
    throw new HttpResponseException(StatusCodes.Status500InternalServerError, "db error", e);
}
if (user == null)
{
    if (txn != null) await txn.RollbackAsync();
    throw new HttpResponseException(StatusCodes.Status401Unauthorized, "user not found");
}

*1:また起こるべきでない状態なので開発中ならバグ修正、本番なら通知を行うようにするのも必要でしょう