Zwracanie błędów z API, czyli ProblemDetails

Zwracanie błędów z API, czyli ProblemDetails

Wstęp

Często zdarza się, że tworzymy własne modele, którymi przesyłamy błędy z API do klienta API. Osobiście często miałem z tym rozkminy, jak to zrobić uniwersalnie. Na szczęście w 2016 roku w życie wszedł standard RFC7807, który opisuje ProblemDetails – uniwersalny sposób przesyłania błędów.

Standard ProblemDetails

Po co powstał ten standard? Przede wszystkim REST ma być uniwersalny. Twórcy dostrzegli problem typu „każdy wuj na swój strój” – każdy robił własne modele i standardy przesyłania informacji o błędach. Chodziło też o usprawnienie automatyzacji.

Przykładowo, człowiek siedzący za monitorem często z kontekstu może wydedukować, czy błąd 404 jest związany z brakiem strony, czy może z brakiem szukanego rekordu w bazie danych. Automaty niekoniecznie.

Czym jest ProblemDetails?

Z punktu widzenia dokumentacji to jest ustandaryzowany model, który wygląda tak:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
 }

Teraz omówię każde z tych pól:

Type

To pole jest wymagane i powinno posiadać adres do strony HTML na temat danego błędu. Informacje mają być w formie, w której człowiek może je przeczytać. Jeśli nie możesz dać żadnego adresu, daj domyślną wartość „about:blank”.

Czy to znaczy, że musisz stworzyć strony HTML z opisem swoich błędów? Nie. Wystarczy, że wpiszesz tam adres standardowy opisujący dany błąd, np.: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4. Generalnie możesz odnieść się do poszczególnych sekcji ze strony: https://www.rfc-editor.org/rfc/rfc7231, na której są opisane już te błędy.

Klient API musi traktować to pole jako główne pole opisu błędu. Tzn. powinien wiedzieć, jak zareagować na przykładową wartość: https://www.rfc-editor.org/rfc/rfc7231#section-6.5.4.

Title

To ma być jakieś podsumowanie problemu czytelne dla człowieka. I tutaj uwaga – zawartość tego pola nie może się zmieniać. Tzn. że kilka „wywołań” tego samego problemu musi dać ten sam tytuł. Czyli nie zmieniaj tego w zależności od kontekstu.

Wyjątkiem od tej reguły może być lokalizowanie treści. Czyli inną możesz dać dla Niemca (po niemiecku), inną dla Polaka (po polsku).

Detail

Tutaj powinien znaleźć się opis konkretnej sytuacji czytelny dla człowieka. O ile pole title nie powinno się zmieniać w zależności od danych, to pole detail jak najbardziej może – tak jak widzisz w przykładzie. Pole jest opcjonalne.

Klient API nie powinien w żaden sposób parsować informacji zawartych w tym polu. Są inne możliwości, które pomagają w automatyzacji, o których piszę później (rozszerzenia).

Status

To pole jest opcjonalne. Jego wartością powinien być kod oryginalnego błędu. Powiesz – „ale po co, skoro kod błędu jest w odpowiedzi na żądanie?”. No niby jest. Ale niekoniecznie musi być oryginalny. Jeśli np. ruch przechodzi przez jakieś proxy albo w jakiś inny sposób jest modyfikowany, wtedy kod w odpowiedzi może być inny niż w oryginalnym wystąpieniu błędu. Dlatego w polu status zawsze podawaj oryginalny kod błędu.

Instance

Tutaj powinno znaleźć się miejsce, które spowodowało błąd. To też powinien być adres URI.

Rozszerzenia

Standard pozwala rozszerzać ProblemDetails. Tzn. możesz dodać do modelu inne właściwości. Teraz możesz powiedzieć: „I tu się kończy uniwersalność, dziękuję, do widzenia.”. I w pewnym sensie będziesz miał rację, ale tylko w pewnym sensie. Zauważ, że cały czas podstawowy model jest taki sam.

Przykład modelu z rozszerzeniem

{
   "type": "https://example.net/validation-error",
   "title": "Your request parameters didn't validate.",
   "status": 400,
   "invalid_params": [ {
                         "name": "age",
                         "reason": "must be a positive integer"
                       },
                       {
                         "name": "color",
                         "reason": "must be 'green', 'red' or 'blue'"}
                     ]
   }

Jak widzisz w przykładzie powyżej, doszło pole „invalid_params„. Takie rozszerzenia mogą być użyteczne dla klientów API lub po prostu posiadać dodatkowe informacje. Pamiętaj, że pole detail nie powinno być w żaden sposób parsowane. Więc, wracając do pierwszego przykładu, moglibyśmy dać dwa rozszerzenia, którymi klient API może się w jakiś sposób posłużyć:

{
    "type": "https://example.com/probs/out-of-credit",
    "title": "You do not have enough credit.",
    "detail": "Your current balance is 30, but that costs 50.",
    "status": 400,
    "instance": "/account/12345/msgs/abc",
    "balance": 30,
    "price": 50
 }

Generalnie rozszerzenia umożliwiają przekazanie większej ilości informacji w sposób, w jaki chcesz.

Czy muszę używać ProblemDetails?

Nie musisz. Jeśli masz już swój mechanizm, który działa, to go zostaw. ProblemDetails nie ma zamieniać istniejących mechanizmów. Ma dawać uniwersalność i brak konieczności tworzenia własnych rozwiązań.

Jeśli jednak zwracasz jakiś konkretny model, zawsze masz opcję żeby zrobić z niego swój ProblemDetails. Po prostu potraktuj swój model jako rozszerzenie. Wystarczy, że dodasz pola wymagane i będzie git.

Ale powtarzam – jeśli masz już swój działający mechanizm, niczego nie musisz robić. Nie musisz tego implementować na siłę. Jednak w nowych projektach – doradzam używanie ProblemDetails.

ProblemDetails w .NET

Na szczęście .NET ma już ogarnięte ProblemDetails i w zasadzie nie musisz tworzyć własnego mechanizmu. Różnice występują między .NET6 i .NET7. Nie piszę o wcześniejszych wersjach jako że już nie są wspierane. Jeśli utrzymujesz jakieś apki poniżej .NET6, powinieneś zmigrować do nowszej wersji.

Zwracanie problemu

W .NET standardowo możesz zwracać Problem w kontrolerze:

[HttpGet]
public IActionResult ReturnProblem()
{
    return Problem();
}

Oczywiście możesz przekazać mu wszystkie pola z modelu standardowego (jako parametry metody Problem). Jeśli tego nie zrobisz, .NET sam je uzupełni domyślnymi wartościami. Z powyższego przykładu uzyskasz taką zwrotkę:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-4139aae6364cb68d2235c576689bb359-88964c6328a1d834-00"
}

Zauważ, że:

  • type odnosi do opisu błędu 500 (bo taki jest domyślnie zwracany przy problemie)
  • title – to jakaś ogólna domyślna wartość
  • status – jak pisałem wyżej, zwraca oryginalny kod błędu
  • traceId – to jest rozszerzenie .NET, które ułatwia szukanie błędów w logach

Piszemy rozszerzenie

Tutaj wszystko sprowadza się do zwrócenia obiektu ProblemDetails zamiast wywołania metody Problem(). Niestety metoda Problem() nie umożliwia dodawania rozszerzeń. Ale nic się nie bój – nie musisz wszystkiego tworzyć sam. Jest dostępna fabryka, którą wykorzystamy. Musisz ją wstrzyknąć do kontrolera, a potem użyć jej, żeby stworzyć instancję ProblemDetails:

public class ProblemController : ControllerBase
{
    private readonly ProblemDetailsFactory _problemDetailsFactory;
    public ProblemController(ProblemDetailsFactory problemDetailsFactory)
    {
        _problemDetailsFactory = problemDetailsFactory;
    }

    [HttpGet]
    public IActionResult ReturnProblem()
    {
        var result = _problemDetailsFactory.CreateProblemDetails(HttpContext);

        var extObject = new
        {
            errorType = "LoginProblem",
            systemCode = 10
        };
        result.Extensions["additionalData"] = extObject;

        return Unauthorized(result);
    }
}

Wszystko powinno być dość jasne. Tylko zwróć uwagę na jedną rzecz. Właściwość Extensions z klasy ProblemDetails to jest słownik. Zwykły Dictionary<string, object>. Co nam to daje? Kluczem musi być string (to będzie nazwa pola), ale za to wartością może być już cokolwiek. Może być string, liczba jakaś, a nawet cały obiekt – to, co zrobiliśmy tutaj.

Utworzyłem jakiś anonimowy obiekt i dodałem go jako rozszerzenie. W efekcie ten obiekt zostanie zserializowany (domyślnie do JSON).

Oczywiście na koniec musimy zwrócić problem posługując się jedną z metod w stylu BadRequest, NotFound, Unauthorized itd. Ja przykładowo wybrałem Unauthorized, ale pewnie w rzeczywistym projekcie, najchętniej użyjesz BadRequest.

W rezultacie otrzymamy coś takiego:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
  "title": "An error occurred while processing your request.",
  "status": 500,
  "traceId": "00-c721ebc1a2be6484c53a9cdd54307e5c-b3ebd2cf05a27d0c-00",
  "additionalData": {
    "errorType": "LoginProblem",
    "systemCode": 10
  }
}

Zwróć uwagę na jedną rzecz. W response będziesz miał kod 401 (bo zwróciliśmy Unauthorized), natomiast w modelu ProblemDetails masz 500 – bo z takim kodem jest tworzony domyślnie ProblemDetails. Oczywiście wypadałoby to ujednolicić, przekazując do fabryki odpowiedni kod błędu:

var result = _problemDetailsFactory.CreateProblemDetails(HttpContext, statusCode: (int)HttpStatusCode.Unauthorized);

Zobacz też, że nigdzie nie musisz podawać właściwości type. I super! .NET sam ma wszystko ładnie zmapowane do strony https://tools.ietf.org/html/rfc7231, na której są już opisane wszystkie błędy.

Właściwość type jest uzupełniana na podstawie przekazanego statusCode. Oczywiście, jeśli potrzebujesz, możesz dać swoje type w parametrze metody Problem() lub fabryki.

Obsługa błędów

Cały mechanizm umożliwia też elegancką obsługę błędów. Jeśli gdziekolwiek zostanie rzucony nieprzechwycony wyjątek, standardowe działanie jest takie, że zwracany jest błąd 500. Natomiast mechanizm ProblemDetails w .NET tworzy odpowiedź z użyciem tego standardu. Niestety… dopiero w .NET7. Tu jest ta różnica. W .NET7 jest to dostępne na „dzień dobry”, natomiast w .NET6 trzeba sobie samemu to dopisać. Chociaż istnieje NuGet Hellang.Middleware.ProblemDetails którego możesz użyć we wcześniejszych wersjach. Ja go nie używałem, więc tylko daję Ci znać, że jest.

Wyjątki i ProblemDetails w .NET6

W .NET6 musisz do tego napisać własny mechanizm (lub posłużyć się wspomnianym Nugetem). Możesz np. napisać middleware, który Ci to ogarnie. Taki middleware w najprostszej postaci mógłby wyglądać tak:

public class ProblemDetailsMiddleware : IMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            await HandleProblemDetails(context, ex);
        }
    }

    private async Task HandleProblemDetails(HttpContext context, Exception ex)
    {
        var factory = context.RequestServices.GetRequiredService<ProblemDetailsFactory>();
        var problem = factory.CreateProblemDetails(context,
            title: "Unhandled exception",
            detail: ex.GetType().ToString());

        problem.Extensions["message"] = ex.Message;
        if (IsDevelopement(context))
            problem.Extensions["stackTrace"] = ex.StackTrace;

        context.Response.StatusCode = problem.Status.Value;
        await context.Response.WriteAsJsonAsync(problem, null, "application/problem+json");
    }

    private bool IsDevelopement(HttpContext context)
    {
        var hostEnv = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
        return hostEnv.IsDevelopment();
    }
}

Jak widzisz, po prostu zapisuję problem do odpowiedzi. Sprawdzam też środowisko. Jeśli jest deweloperskie, to nie waham się pokazać stack trace 🙂 W innym przypadku – ze względów bezpieczeństwa lepiej tego nie robić. W takim przypadku musisz też uważać na to, jakie komunikaty dajesz, gdy rzucasz jakiś wyjątek – nie mogą być drogowskazem dla hackerów. Pisałem o tej „podatności” w książce „Zabezpieczanie aplikacji internetowych w .NET – podstawy„.

To jest najprostszy możliwy middleware. Dobrze byłoby również zalogować taki błąd.

Oczywiście musisz pamiętać o dodaniu tego middleware do pipeline:

builder.Services.AddScoped<ProblemDetailsMiddleware>();

var app = builder.Build();
app.UseMiddleware<ProblemDetailsMiddleware>();

Pamiętaj, że middleware’y obsługujące wyjątki powinny być uruchamiane jak najwcześniej w pipeline.

Wyjątki i ProblemDetails w .NET7

Jak już pisałem, w .NET7 to masz „out-of-the-box”. Wystarczy zrobić dwie rzeczy. Podczas rejestracji serwisów dodaj serwisy od ProblemDetails:

builder.Services.AddProblemDetails();

A przy konfiguracji middleware pipeline włóż obsługę wyjątków:

app.UseExceptionHandler();

A jeśli byś chciał dodać jakieś swoje rozszerzenie do ProblemDetails przy niezłapanym wyjątku, wystarczy dodać serwisy z odpowiednim przeciążeniem, np:

builder.Services.AddProblemDetails(c =>
{
    c.CustomizeProblemDetails = (ctx) =>
    {
        ctx.ProblemDetails.Extensions.Add("user_logged", ctx.HttpContext.User?.Identity?.IsAuthenticated);
    };
});

W tym przykładzie, do każdego ProblemDetails zostanie dołożone moje rozszerzenie; pole „user_logged„, mówiące czy użytkownik jest zalogowany, czy nie. W obiekcie ctx (ProblemDetailsContext) mamy do dyspozycji m.in. HttpContext z żądania. Więc jeśli potrzebujesz jakiś dodatkowych informacji, to proszę bardzo.

Bezpieczeństwo

Pamiętaj, że komunikat błędu może tworzyć jakiś drogowskaz dla hackerów. A tym bardziej cały stack trace. Na szczęście mechanizm ProblemDetails w .NET7 jest bezpieczny. Dopóki nie zrobisz niczego bardzo głupiego, to będzie dobrze. Stack Trace zostanie dołączone do modelu ProblemDetails tylko na środowisku Developerskim. Natomiast na każdym innym nie zobaczysz go. I to dobrze.

Pamiętaj jednak, żeby samemu nie podawać zbyt dużo informacji w ProblemDetails, bo to może pomóc hackerom. Zresztą pisałem o tym w książce Zabezpieczenia aplikacji internetowych w .NET – podstawy


Dzięki za przeczytanie artykułu. To tyle jeśli chodzi o podstawy użycia ProblemDetails. Jeśli znalazłeś w artykule jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Obraz autorstwa gpointstudio na Freepik

Podziel się artykułem na: