Jak obsłużyć zwracanie wyjątku w WebAPI

Aktualizacja 13.10.2020

Dodałem punkt dotyczący obsługi wyjątków przy użyciu middleware.

Ostatnio pisałem o tym, co możemy zrobić, gdy nasze metody zwracają rezultat i chcemy go zmapować na odpowiedni kod http (tutaj). W tym poście podam podobne rozwiązanie, gdy nasze metody rzucają wyjątki, zamiast zwracać rezultat.

Nasz kontroler:

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    var item = await _itemsReadService.RetrieveByIdAsync(itemId);
    return Ok(item);
}

Metoda RetrieveByIdAsync zwraca oczekiwany zasób, a jak coś pójdzie nie tak, to rzuca wyjątek.

Przykładowe wyjątki, jakie mogą być rzucane:

public class ValidationException : Exception
{
    // ...
}

public class NotFoundException : Exception
{
    // ...
}

public class AuthorizationException : Exception
{
    // ...
}

Metoda

Obecnie te wszystkie wyjątki nie są nigdzie obsługiwane. Analogicznie jak dla rezultatu, tutaj również możemy stworzyć metodę, która złapie te wyjątki i zamieni je na odpowiedni kod http:

protected async Task<IActionResult> HandleException(Func<Task<IActionResult>> action)
{
    try
    {
        return await action();
    }
    catch (ValidationException ex)
    {
        return BadRequest(ex.Message);
    }
    catch (NotFoundException ex)
    {
        return NotFound(ex.Message);
    }
    catch (AuthorizationException ex)
    {
        return new ObjectResult(e.Message)
        {
            StatusCode = StatusCodes.Status403Forbidden
        };
    }
}

Nasz kontroler z uwzględnieniem tej metody:

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    async Task<IActionResult> Action() => Ok(await _itemsReadService.RetrieveAllAsync());
    return HandleExceptions(Action);
}

Musimy pamiętać, aby wszystkie metody w naszych kontrolerach tak opakować. Ta metoda może zostać umieszczona w kontrolerze bazowym, aby wszystkie kontrolery miały do niej dostęp. Dodatkowo, gdy pojawi się nowy wyjątek, wystarczy że dodamy go do metody HandleExceptions i wszystkie metody będą go obsługiwać.

Niestety minusem takiego rozwiązania jest to, że zamiast po prostu wywołać naszą metodę (w tym przypadku RetrieveByIdAsync), takie wywołanie musimy przekształcić na akcję i ją przekazać do metody HandleExceptions.

Filtr

Innym podejściem jest stworzenie odpowiednich filtrów, które będą obsługiwać odpowiednie wyjątki. Takie filtry będą to robić automatycznie, więc nie będziemy musieli wywoływać żadnej dodatkowej metody, ani tworzyć akcji.

Możemy stworzyć jeden filtr, który obsłuży wszystkie przypadki. W tym celu wykorzystać nasz GlobalExceptionFilter:

public class GlobalExceptionFilter : IAsyncExceptionFilter
{
    public async Task OnExceptionAsync(ExceptionContext context)
    {
        context.Result = context.Exception switch
        {
            ValidationException ex => new BadRequestObjectResult(ex.Message),
            NotFoundException ex => new NotFoundObjectResult(ex.Message),
            AuthorizationException ex => new ObjectResult(ex.Message)
            {
                StatusCode = StatusCodes.Status403Forbidden
            },
            _ => new BadRequestObjectResult(context.Exception.Message)
        };
    }
}

Wiele filtrów

Jeśli nie chcemy mieć wszystkiego w jednym pliku, możemy to rozbić na wiele filtrów:

public class ValidationExceptionFilter : IAsyncExceptionFilter, IOrderedFilter
{
    public int Order { get; }

    public async Task OnExceptionAsync(ExceptionContext context)
    {
        if (!(context.Exception is ValidationException exception))
            return;

        context.Result = new BadRequestObjectResult(exception.Message);
        context.ExceptionHandled = true;
    }
}
public class NotFoundExceptionFilter : IAsyncExceptionFilter, IOrderedFilter
{
    public int Order { get; }

    public async Task OnExceptionAsync(ExceptionContext context)
    {
        if (!(context.Exception is NotFoundException exception))
            return;

        context.Result = new BadRequestObjectResult(exception.Message);
        context.ExceptionHandled = true;
    }
}
public class AuthorizationExceptionFilter : IAsyncExceptionFilter, IOrderedFilter
{
    public int Order { get; }

    public async Task OnExceptionAsync(ExceptionContext context)
    {
        if (!(context.Exception is AuthorizationException exception))
            return;

        context.Result = new ObjectResult(exception.Message)
        {
            StatusCode = StatusCodes.Status403Forbidden
        };
        context.ExceptionHandled = true;
    }
}

Do tego przyda nam się jeszcze filtr, który złapie nieobsłużone wyjątki:

public class UnhandledExceptionFilter : IAsyncExceptionFilter, IOrderedFilter
{
    public int Order { get; }

    public async Task OnExceptionAsync(ExceptionContext context)
    {
        context.Result = new BadRequestObjectResult(context.Exception.Message);
        context.ExceptionHandled = true;
    }
}

Musimy jeszcze pamiętać o rejestracji tych wszystkich filtrów w naszym kontenerze:

services.AddControllers(options =>
{
    options.Filters.Add<UnhandledExceptionFilter>(0);
    options.Filters.Add<AuthorizationExceptionFilter>(1);
    options.Filters.Add<NotFoundExceptionFilter>(2);
    options.Filters.Add<ValidationExceptionFilter>(3);
});

Dzięki temu, że nasze filtry implementują interfejs IOrderedFilter, możemy ustawić im kolejność, w jakiej będą wywoływane. Im wyższy numer, tym filtr wywołuje się wcześniej (ma większy priorytet). Należy pamiętać, aby filtr UnhandledExceptionFilter wykonywał się jako ostatni (miał najniższy numer).

Gdy mamy filtry (jeden lub wiele), nasz kontroler wraca do pierwotnej postaci:

[HttpGet("{itemId}")]
public async Task<IActionResult> RetrieveByIdAsync(Guid itemId)
{
    var item = await _itemsReadService.RetrieveByIdAsync(itemId);
    return Ok(item);
}

Middleware

Alternatywą do filtrów jest napisanie oprogramowania pośredniczącego (middleware). Tutaj również łapanie wyjątków i zwracanie odpowiedniego kodu Http będzie się dziać automatycznie.

Taki middleware może wyglądać następująco:

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
        catch (NoFoundException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.NotFound;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
        catch (AuthorizationException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
        catch (Exception e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
    }
}

Musimy jeszcze pamiętać o rejestracji naszego middleware:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...
    app.UseMiddleware<ExceptionHandlingMiddleware>();
    // ...
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
    // ...
}

Warto tutaj wspomnieć, że aby nasz middleware działał poprawnie, musi on zostać zarejestrowany przed rejestracją endpointów. Inaczej mówiąc metodę UseMiddleware musimy wywołać przed metodą UseEndpoints.

Wiele middleware

Tutaj także zamiast jednego middleware, który obsłuży wszystkie przypadki, możemy stworzyć kilka mniejszych:

public class ValidationExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ValidationExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
    }
}
public class NoFoundExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public NoFoundExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (NoFoundException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.NotFound;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
    }
}
public class AuthorizationExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public AuthorizationExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (AuthorizationException e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
    }
}
public class UnhandledExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public UnhandledExceptionHandlingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception e)
        {
            context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
            context.Response.ContentType = "text/plain";
            await context.Response.WriteAsync(e.Message);
        }
    }
}

I jeszcze rejestracja:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...

    app.UseMiddleware<UnhandledExceptionHandlingMiddleware>();
    app.UseMiddleware<AuthorizationExceptionHandlingMiddleware>();
    app.UseMiddleware<NoFoundExceptionHandlingMiddleware>();
    app.UseMiddleware<ValidationExceptionHandlingMiddleware>();

    // ...

    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
    
    // ...
}

Kolejność rejestracji naszych middleware ma znaczenie. Są one wywoływane w odwrotnej kolejności, niż zostały zarejestrowane. Dlatego jako pierwszy powinien zostać zarejestrowany middleware ogólny, a następnie szczególne.

4 myśli na “Jak obsłużyć zwracanie wyjątku w WebAPI”

  1. Pingback: dotnetomaniak.pl

    1. To prawda, poprzez middleware też można 🙂 Nie pomyślałem o tym na początku, bo rzadko piszę middlewary, ale na dniach postaram się uzupełnić ten i poprzedni post o przykład z middleware. Dzięki za sugestię!

Leave a Reply