Badaj swoje API, czyli healthcheck

Badaj swoje API, czyli healthcheck

Spis treści

  1. Wstęp
  2. Czym jest healthcheck?
  3. Konfiguracja healthcheck
  4. Jak działa ten mechanizm?
  5. Badanie zdrowia standardowych serwisów
  6. Sprawdzenie bazy danych
  7. Jak zrobić niestandardowe sprawdzenie
  8. Jak pokazywać wynik healthcheck w niestandardowy sposób
  9. Zabezpieczanie healthcheck – uwierzytelnianie
  10. Kilka różnych końcówek – filtrowanie
  11. Dodanie healthcheck do Swaggera

Wstęp

Czy Twoja webówka działa? A jaką masz pewność? Musiałbyś co chwilę klikać i sprawdzać. Ale są też inne, lepsze metody. Możesz na przykład posłużyć się rozwiązaniem chmurowym, które cyklicznie będzie badać stan Twojej aplikacji. Co więcej, jest opcja, że nawet wyśle Ci maila albo SMSa, jeśli coś będzie nie tak.

Ten artykuł nie opowiada jednak o chmurowej części rozwiązania (jeśli chcesz taki materiał, daj znać w komentarzu), a o aplikacyjnej części. Czyli o healthcheck.

Czym jest Healthcheck?

Healthcheck jest sprawdzeniem stanu Twojej aplikacji. Czy działa wszystko ok, ewentualnie co nie działa. I oczywiście mógłbyś napisać sobie własny kontroler z odpowiednimi endpointami, w których to wszystko sprawdzasz, ale w .NET mamy już taki mechanizm w standardzie. I działa całkiem przyzwoicie.

Po co to właściwie?

Dzisiaj utrzymanie niezawodności i ciągłości działania aplikacji jest priorytetem. Stworzenie skutecznego mechanizmu healthcheck pozwala na szybką reakcję w razie wystąpienia jakiś problemów z jednym z kluczowych elementów systemu.

Jak już pisałem wcześniej, można to nawet spiąć z chmurą i spodziewać się maila albo nawet SMS gdy tylko coś niedobrego zacznie się dziać w Twojej aplikacji.

Przykładowa apka

Do tego artykułu stworzyłem przykładową aplikację, którą możesz pobrać z GitHuba. Po jej pobraniu, koniecznie uruchom migracje Entity Framework.

Niech nasza aplikacja zwraca różne dane pogodowe. Jeśli chodzi o prognozy, będą pobierane z zewnętrznego serwisu, a jeśli chodzi o dane archiwalne (prognoza z przeszłości), będą pobierane z naszej bazy danych.

Zewnętrzny serwis to oczywiście jakiś mock, który będzie udawał połączenie z zewnętrznym API.

Konfiguracja healthcheck

Podstawowa konfiguracja jest zabójczo prosta, bo sprowadza się tylko do rejestracji odpowiednich serwisów i dodania middleware. Czyli mamy coś takiego:

builder.Services.AddControllers();

//healthcheck
builder.Services.AddHealthChecks();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();

app.UseAuthorization();

app.MapHealthChecks("/_health");

app.MapControllers();

app.Run();

Jeśli chodzi o linię 4 – to dodajemy do dependency injection serwisy do sprawdzenia stanu zdrowia. Co się tyczy linii 13, to mapujemy te healthchecki do konkretnego endpointa. W parametrze przekazujemy, pod jakim adresem ma być ten healthcheck. W tej sytuacji to będzie https://localhost:pppp/_health, gdzie pppp to oczywiście numer portu na środowisku lokalnym.

Teraz jeśli uruchomimy aplikację i sprawdzimy ten healthcheck, dostaniemy zwrotkę ze StatusCode 200 OK i wartością:

Healthy

Przyznasz jednak, że takie sprawdzenie niewiele nam daje. No właśnie, domyślny mechanizm właściwie niczego nie sprawdza. Jeśli aplikacja chodzi, to zawsze zwróci Healthy. A my chcemy sprawdzić przynajmniej dwie rzeczy:

  • czy działa połączenie z bazą danych
  • czy działa połączenie z zewnętrznym serwisem

Możemy wszystko napisać ręcznie, ale jest lepsza metoda. Nudesy…eeee Nugetsy 😉 O tym za chwilę.

Jak działa mechanizm healthcheck?

Metoda AddHealthChecks zwraca nam interfejs IHealthChecksBuilder, dodając jednocześnie domyślny serwis do sprawdzenia HealthChecków, który wszystkim zarządza. I tak naprawdę możemy sobie stworzyć listę healthchecków, jakie chcemy mieć. To wszystko sprowadza się do dodania do tego buildera klasy, która implementuje odpowiedni interfejs (o tym też będzie za chwilę).

Potem ten domyślny serwis bierze sobie te wszystkie klasy, tworzy je i wywołuje po kolei metodę sprawdzającą. Ot, cała magia. Dzięki czemu możemy tworzyć sobie właściwie nieograniczone sprawdzenia stanu zdrowia apki.

Badanie standardowych serwisów

Jeśli wejdziesz sobie do managera nugetów i zaczniesz wpisywać AspNetCore.Healthchecks, oczom Twym ukaże się całkiem pokaźna lista z już oprogramowanymi sprawdzeniami do konkretnych serwisów:

To nie są w prawdzie oficjalne Microsoftowe paczki, jednak społeczność która za tym stoi, to (w momencie pisania artykułu) ponad 150 osób. Jeśli używasz jakiegoś standardowego serwisu, to jest duża szansa, że sprawdzenie healthcheka do niego już istnieje.

Sprawdzanie bazy danych

Oczywiście możemy sobie sprawdzić różne bazy danych, w tym MSSQL, Postgre, MySQL, Redis itd. – używając bibliotek z powyższej listy. Możemy też użyć oficjalnej paczki Microsoft.Extensions.Diagnostics.Healthcheck, która umożliwia testowanie całego kontekstu bazy danych (EFCore). A jak używać tych wszystkich bibliotek?

Metoda AddHealthChecks zwraca nam interfejs IHealthChecksBuilder i wszystkie rozszerzenia jakie mamy dostępne są rozszerzeniami właśnie tego interfejsu. A prostymi słowami:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(); //rozszerzenie z Microsoft.Extensions.Diagnostics.Healthcheck

W taki sposób możemy dodać sprawdzenie, czy baza danych działa. Domyślnie, sprawdzane jest połączenie z bazą danych za pomocą metody dbContext.Database.CanConnectAsync(cancellationToken);

Jednak niech nie zwiedzie Cię ta pozorna prostota. Jeśli chodzi o bazę MSSQL, to ta metoda faktycznie próbuje połączyć się z bazą danych, a potem wysyła zapytanie SELECT 1.

Oczywiście można zrobić więcej – wystarczy dodać jakieś parametry, np.:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(customTestQuery: async (ctx, token) =>
    {
        await ctx.WeatherArchives.CountAsync();
        return true;
    });

W tym momencie domyślne sprawdzenie zostanie zamienione na nasze. Czyli podczas sprawdzenia stanu zdrowia bazy danych, baza zostanie odpytana o ilość rekordów w tabeli WeatherArchives – którą mamy zdefiniowaną w naszym AppDbContext. Parametr CustomTestQuery to po prostu funkcja, która przyjmuje w parametrze nasz kontekst bazy danych i CancellationToken, a zwraca jakiś bool.

I co najważniejsze – ten kod wystarczy. Nie trzeba tutaj stosować żadnych try..catch’y, ponieważ cała nasza funkcja i tak jest wywoływana w kontekście try..catch. Więc jeśli wystąpi jakiś exception, mechanizm healthcheck zwróci nam odpowiednią informację.

Niemniej jednak przy standardowych zastosowaniach, standardowy mechanizm sprawdzania bazy danych jest w zupełności wystarczający.

Sprawdzanie niestandardowe

Jednak nasz przykładowy serwis ForecastService, który ma imitować klienta jakiegoś zewnętrznego API, jest niestandardowym serwisem i nie znajdziemy biblioteki dla niego. Mechanizm HealthCheck pozwala jednak na napisanie własnego HealthChecka – dokładnie w taki sam sposób w jaki powstają te biblioteki wyżej pokazane.

Utworzenie klasy do sprawdzenia zdrowia

W pierwszej kolejności musimy utworzyć klasę, która implementuje interfejs IHealthCheck. Interfejs ma tylko jedną metodę, którą musimy napisać.

Teraz załóżmy, że nasz serwis, który udaje klienta API do pobierania prognozy pogody wygląda tak:

public class ForecastService(RandomHelper _randomHelper)
{
    public async Task<WeatherData> GetForecastFor(string city, DateOnly date)
    {
        await Task.Delay(500);
        return new WeatherData
        {
            City = city,
            Date = date,
            TemperatureC = _randomHelper.GetRandomTemperature()
        };
    }

    public async Task<bool> IsServiceHealthy()
    {
        await Task.Delay(500);
        return true;
    }

Czyli sprawdzenie stanu zdrowia tego serwisu będzie wymagało tylko wywołania metody IsServiceHealthy, którą nam daje nasz oszukany klient. A jak to zrobić? No oczywiście w klasie implementującej IHealthCheck:

public class ForecastServiceHealthCheck(ForecastService _forecastService) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, 
        CancellationToken cancellationToken = default)
    {
        var result = await _forecastService.IsServiceHealthy();
        if (result)
            return HealthCheckResult.Healthy();
        else
            return HealthCheckResult.Unhealthy();
    }
}

W metodzie CheckHealthAsync musimy teraz zwrócić HealthCheckResult – rezultat, który mówi, czy testowany podsystem jest zdrowy, czy nie. Domyślne stany Healthy i Unhealthy zazwyczaj wystarczą.

Oczywiście w klasie implementującej IHealthCheck możesz zrobić dowolny kod. Jeśli masz faktyczne zewnętrzne API, do którego się łączysz, możesz mieć tutaj po prostu HttpClienta, za pomocą którego wyślesz jakiś request.

I tak jak pisałem wcześniej – takich klas możesz sobie utworzyć tyle, ile potrzebujesz.

A jak ją zarejestrować? Też cholernie prosto:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddCheck<ForecastServiceHealthCheck>("Forecast service");

Czyli wywołujemy metodę AddCheck. W parametrze generycznym przekazujemy typ klasy, w której zaimplementowaliśmy sprawdzenie, a w parametrze Name przekazujemy jakąś nazwę dla tego sprawdzenia. W tym wypadku: "Forecast service", bo sprawdzamy działanie właśnie tego serwisu.

Pokazywanie większej ilości informacji

W tym momencie, jeśli strzelimy na końcówkę z healthcheckiem dostaniemy odpowiedź w formie czystego stringa – Healthy albo Unhealthy. Ale możemy to zmienić w dość łatwy sposób. Najprościej pobrać sobie Nugeta: AspNetCore.Healthchecks.UI.Client i podczas dodawania healthchecków do middleware dodać opcje:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse //UIResponseWriter pochodzi z ww. Nugeta
});

I teraz dostaniemy dużo więcej informacji. Np. przy działającej aplikacji:

{
  "status": "Healthy",
  "totalDuration": "00:00:08.0064214",
  "entries": {
    "AppDbContext": {
      "data": {},
      "duration": "00:00:06.9029003",
      "status": "Healthy",
      "tags": []
    },
    "Forecast service": {
      "data": {},
      "duration": "00:00:00.5291383",
      "status": "Healthy",
      "tags": []
    }
  }
}

Zwróć uwagę, że otrzymujemy główny status apki i statusy poszczególnych serwisów, które sprawdzamy. AppDbContext to oczywiście sprawdzenie bazy danych. A Forecast service – to jest to, co sami pisaliśmy. Przy błędzie możemy uzyskać coś takiego:

{
  "status": "Unhealthy",
  "totalDuration": "00:00:00.5979899",
  "entries": {
    "AppDbContext": {
      "data": {},
      "duration": "00:00:00.0768822",
      "status": "Healthy",
      "tags": []
    },
    "Forecast service": {
      "data": {},
      "duration": "00:00:00.5204673",
      "status": "Unhealthy",
      "tags": []
    }
  }
}

Tutaj nie zadziałał serwis do prognoz.

Generalnie właściwość ResponseWriter przy mapowaniu tych healthchecków daje nam opcje takiego stworzenia odpowiedzi jaką chcemy. Jeśli ta domyślna z Nugeta daje za mało info albo trochę za dużo, sami możemy coś pokombinować, np.:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = async (httpContext, healthReport) =>
    {
        await httpContext.Response.WriteAsJsonAsync(healthReport);
    }
});

ResponseWriter to po prostu funkcja, która dostaje w parametrze HttpContext i HealthReport, a zwraca Task. Jej zadaniem jest wypisanie do responsa tego, co chcemy zobaczyć w odpowiedzi na ten endpoint.

Więc możemy sobie tutaj skonstruować odpowiedź jaka nam się tylko podoba. Możemy np. napisać sobie funkcje, która zwróci nam informacje o wersji albo co sobie tam wymyślimy.

Dodatkowe możliwości

Jeśli przyjrzysz się metodzie AddCheck z IHealthCheckBuilder, zobaczysz że ma ona dodatkowe parametry, które możesz przekazać. Wszystkie parametry trafią później do HealthCheckStatus – parametr w metodzie, w której tworzysz sprawdzenie – jak robiliśmy wyżej z ForecastServiceHealthCheck.

Dodatkowo możesz umieścić tam np. timeout. Mechanizm healthcheck mierzy czas wykonania każdego sprawdzenia. Jeśli przekażesz timeout i ten czas zostanie przekroczony, no to też dostaniesz odpowiednią informację.

Jeśli chodzi o listę tags, to możesz sobie wrzucić tam jakieś dodatkowe informacje, które są Ci potrzebne. O tym będzie jeszcze niżej.

Zabezpieczenie healthchecka – uwierzytelnianie

Zastanów się, czy każdy powinien mieć dostęp do Twojego healthchecka. Być może po drugiej stronie siedzi gdzieś ciemny typ, który próbuje hackować Twój system i zastanawia się jak po różnych krokach wygląda healthcheck. Jeśli dojdziesz do wniosku, że tylko niektóre osoby (maszyny) powinny mieć do tego dostęp, łatwo to ogarnąć.

W momencie, w którym mapujesz końcówkę healthchecka możesz dodać zabezpieczenia:

app.MapHealthChecks("/_health")
    .RequireHost("localhost");

Po takiej konfiguracji, będzie można dobić się do healthchecka tylko z domeny localhost. Próba dojścia z innej da po prostu zwrotkę 404 Not Found. Możesz też pokombinować mocniej. Np. wymusić konkretny port z dowolnego hosta:

app.MapHealthChecks("/_health")
    .RequireHost("*:5001");

Takich metod Require* mamy kilka, których możemy używać do różnych ograniczeń.

  • RequireCors – będzie wymagało odpowiedniej polityki CORS. O CORSach pisałem tutaj,
  • RequireAuthorization – będzie wymagało uwierzytelnionego użytkownika. Jak to zrobisz, to już jest Twoja sprawa. W tej metodzie możesz podać politykę autoryzacyjną, możesz też role, schematy. Jeśli nie podasz niczego, będzie użyty domyślny schematy uwierzytelniania,
  • RequireRateLimiting – da Ci rate limiting na tej końcówce 🙂 Po krótce, jest to mechanizm, który pozwala uderzyć w dane miejsce nie częściej niż ileś tam razy. Czyli np. możesz sobie ustawić raz na minutę.

Te ograniczenia możesz ze sobą również łączyć. Nic nie stoi na przeszkodzie, żeby mógł dobić się tylko uwierzytelniony użytkownik z konkretnego hosta i to nie częściej niż co jakiś czas:

app.MapHealthChecks("/_health")
    .RequireAuthorization()
    .RequireHost("localhost:5101")
    .RequireRateLimiting(...);

Filtrowanie healthchecków i kilka końcówek

Z jakiegoś powodu możesz chcieć uruchamiać tylko niektóre sprawdzenia. Domyślny mechanizm uruchamia wszystkie zarejestrowane HealthChecki. Ale możesz stworzyć filtrować te serwisy i pozwalać na uruchamianie tylko niektórych. Ponadto, możesz mieć więcej końcówek dla różnych sprawdzeń. Na przykład:

app.MapHealthChecks("/_health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.MapHealthChecks("/_health_db", new HealthCheckOptions
{
    Predicate = healthCheck => healthCheck.Tags.Contains("db")
});

Tutaj stworzyłem dwie końcówki:

  • _health – sprawdzi wszystkie zarejestrowane healthchecki i zwróci rezultat w JSON (tak jak pokazywałem wyżej)
  • _health_db – sprawdzi tylko te healthchecki, które zwróci Predicate – w tym wypadku te, które mają w swoich tagach słowo "db". I rezultat będzie zwrócony w standardowy sposób, czyli dostaniesz tylko informację Healthy lub Unhealthy (w tej końcówce nie posługujemy się ResponseWriterem).

A skąd ten filtr ma wiedzieć, że tag „db” oznacza bazę danych? Na szczęście to nie jest żadna magia i sam musisz zadbać o to, żeby do odpowiednich serwisów dodać odpowiednie tagi. Robisz to podczas ich rejestracji, np. tak:

//healthcheck
builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>(tags: new string[] { "db" })
    .AddCheck<ForecastServiceHealthCheck>("Forecast service");

Zwróć uwagę, jak w trzeciej linijce dodałem tagi do serwisu badającego bazę danych.

Dodanie endpointa do Swaggera

Skoro to czytasz, to zapewne zauważyłeś, że tak stworzony endpoint dla healthcheck nie jest dodawany do Swaggera. I przy obecnej technologii, gdzie możemy używać Postmana i requestów prosto z VisualStudio (plik *.http) nie widzę w tym większego sensu, ale się da. Wystarczy stworzyć i zarejestrować swój własny DocumentFilter.

IDocumentFilter to interfejs, który dostarcza informacji o dodatkowych operacjach. Standardowo Swagger szuka po prostu kontrolerów i akcji w nich i na tej podstawie (używając refleksji) tworzy swoją dokumentację. Oczywiście można mu dodać operacje, które nie są obsługiwane przez kontrolery. Wystarczy zaimplementować ten interfejs IDocumentFilter:

public class HealthCheckDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        var healthCheckOp = CreateHealthcheckOperation("_health", true);
        var dbHealthCheckOp = CreateHealthcheckOperation("_health_db", false);

        var healthPathItem = new OpenApiPathItem();
        healthPathItem.AddOperation(OperationType.Get, healthCheckOp);

        var dbHealthCheckPathItem = new OpenApiPathItem();
        dbHealthCheckPathItem.AddOperation(OperationType.Get, dbHealthCheckOp);

        swaggerDoc.Paths.Add("/_health", healthPathItem);
        swaggerDoc.Paths.Add("/_health_db", dbHealthCheckPathItem);
    }

    private OpenApiOperation CreateHealthcheckOperation(string endpoint, bool returnsJson)
    {
        var result = new OpenApiOperation();
        result.OperationId = $"{endpoint}OperationId";

        var mediaType = returnsJson ? "application/json" : "text/plain";
        var objType = returnsJson ? "object" : "string";
        var schema = new OpenApiSchema
        {
            Type = objType
        };

        var response = new OpenApiResponse
        {
            Description = "Success"
        };

        response.Content.Add(mediaType, new OpenApiMediaType { Schema = schema });
        result.Responses.Add("200", response);

        return result;
    }
}

No i musimy go zarejestrować podczas rejestracji Swaggera:

builder.Services.AddSwaggerGen(o =>
{
    o.DocumentFilter<HealthCheckDocumentFilter>();
});

Nie będę omawiał tego kodu, bo nie ma nic wspólnego z healthcheckiem, tylko z dodawaniem operacji do Swaggera. Jest dość prosty i intuicyjny. Po prostu musimy dodać konkretne operacje (OpenApiOperation) do konkretnych endpointów (swaggerDoc.Paths.Add) i tyle. A każda operacja może składać się z różnych opisów, zwrotek itd. To wszystko co tutaj podasz, będzie potem widoczne w odpowiednich opisach na stronie Twojej dokumentacji.


To tyle na dzisiaj. Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu. Jeśli chciałbyś jakąś dodatkową wiedzę na temat healthchecków, to też daj znać. No i koniecznie podziel się tekstem z osobami, którym się przyda 🙂

Podziel się artykułem na:
Dokumentowanie własnego API automatem – co to Swagger?

Dokumentowanie własnego API automatem – co to Swagger?

Wstęp

Któż z nas nie kocha pisania dokumentacji? 😉 No właśnie. Nikt tego nie chce robić, ale każdy chciałby mieć dokumentację do zewnętrznych systemów. Niestety tworzenie takich materiałów jest po prostu upierdliwe… Ale nie musi. W tym artykule pokażę Ci jak szybko i prosto zrobić bardzo funkcjonalną dokumentację dla własnego API.

Co to Swagger?

Swagger to narzędzie, które magicznie skanuje Twoje API i tworzy stronę, na której ładnie opisuje wszystkie końcówki. Co więcej, umożliwia testowanie takiego API na żywym organizmie. To jest dokumentacja w pełni interaktywna.

Wszystko zrobisz w Visual Studio – nie musisz otwierać żadnego innego edytora. Zaczynamy.

Dodawanie Swaggera do projektu

Swagger jest tak fajnym narzędziem, że Microsoft pozwala na dodanie go już podczas tworzenia samego projektu. W oknie konfiguracji możesz wybrać, czy go używać, czy nie.

To oczywiście najprostsza droga do dodania Swaggera. Ale być może jest tak, że masz projekt, w którym nie zaznaczyłeś tej opcji. Tak też się da.

Dodawanie Swaggera ręcznie

Pobierz NuGet:

Install-Package Swashbuckle.AspNetCore.Swagger

Teraz musisz skonfigurować Swaggera.

Dodaj go przy rejestracji serwisów:

builder.Services.AddControllers();
builder.Services.AddSwaggerGen();

A podczas konfiguracji pipeline dodaj:

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

Tutaj mała uwaga. Być może pracujesz nad API, które będzie wystawiane zewnętrznie dla klientów. W takim przypadku prawdopodobnie nie powinieneś dodawać Swaggera tylko w środowisku deweloperskim ale produkcyjnie też.

I teraz małe wyjaśnienie:

  • builder.Services.AddSwaggerGen(); – rejestruje serwisy potrzebne do obsługi Swaggera
  • app.UseSwagger(); – to podstawowa obsługa
  • app.UseSwaggerUI(); – dodaje do Twojego API specjalną stronę, na której wszystko jest ładnie opisane, a w dodatku można testować.

To tyle, jeśli chodzi o podstawową konfigurację.

Przykładowy projekt

Swaggera najlepiej pokazać na przykładzie. W związku z tym przygotowałem prostą solucję, składającą się z dwóch projektów. SwaggerDemo to jest nasze API, SwagerDemo.Models to projekt przechowujący modele aplikacji. Specjalnie są zrobione dwa projekty, żeby Ci pokazać coś więcej. Cały gotowy kod możesz sobie sklonować z GitHuba: https://github.com/AdamJachocki/SwaggerDemo

Jeśli nie chcesz korzystać z mojego projektu, po prostu dodaj Swaggera do swojego (tak jak to opisane wyżej).

Możesz teraz uruchomić projekt API. Ważne, żeby API otwierało przeglądarkę. Jeśli Twoje nie otwiera, możesz zmodyfikować plik Properties/launchSettings.json, zmieniając wartość zmiennej launchBrowser na true. Możesz też automatem otworzyć stronę Swaggera, dodając do lauchSettings.json zmienną launchUrl:

"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7090;http://localhost:5090",
"environmentVariables": {
  "ASPNETCORE_ENVIRONMENT": "Development"

Jeśli dodawałeś Swaggera automatycznie (lub zmodyfikowałeś launchSettings.json jak wyżej), prawdopodobnie od razu pokazuje Ci się jego strona. Jeśli nie, doklep w przeglądarce końcówkę swagger. Przykładowo, jeśli adres Twojego API to http://localhost:5001, przejdź na: http://localhost:5001/swagger.

Tak mniej więcej wygląda podstawowa dokumentacja wygenerowana Swaggerem. Osobno widzisz każdy kontroler, w kontrolerze kolekcję endpointów, każdy rodzaj endpointa (POST, GET, DELETE) ma swój kolor. Jeśli rozwiniesz endpoint, zobaczysz dokładnie jakie przyjmuje dane, co zwraca i będziesz mógł go wywołać (przycisk Try it out z prawego, górnego narożnika). Swagger automatycznie rozpoznaje dane wchodzące:

Niemniej jednak, zgodzisz się że to słaba dokumentacja i właściwie niczego nie mówi. Poza tym, że pozwala Ci wysłać żądanie po kliknięciu przycisku Try it out. Ale spokojnie. Zaraz się tym zajmiemy.

Dokumentacja generowana z komentarzy

Każdy endpoint możesz dokładnie opisać za pomocą komentarzy dokumentujących, np:

/// <summary>
/// Pobiera użytkownika po przekazanym id
/// </summary>
/// <param name="id">Id użytkownika</param>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Tekst jaki wpisałeś w <summary> pojawi się jako opis konkretnego endpointa. Natomiast opisy parametrów <param> pojawią się przy parametrach. Jednak żeby to zadziałało, musisz dokonfigurować projekt API.

Konfiguracja projektu

W pliku projektu dodaj:

<GenerateDocumentationFile>True</GenerateDocumentationFile>

Możesz też zrobić to z poziomu ustawień projektu: Build -> Output -> Documentation file:

To ustawienie sprawi, że VisualStudio podczas budowania aplikacji, utworzy specjalny plik XML z metadanymi dokumentacji. Plik będzie nazywał się tak jak projekt, np: SwaggerDemo.xml. I domyślnie tworzy się w katalogu wynikowym.

To ustawienie jednak spowoduje również mały efekt uboczny. Podczas budowania aplikacji otrzymasz warningi CS1591, mówiące o tym, że są publiczne metody, które nie mają komentarzy dokumentujących. My tutaj dokumentujemy tylko metody w kontrolerach, aby Swagger mógł zadziałać. Jeśli nie dokumentujesz wszystkich metod publicznych, możesz ten warning wyłączyć, dodając do pliku projektu:

<NoWarn>$(NoWarn);1591</NoWarn>

Mój plik projektu API wygląda teraz tak:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SwaggerDemo.Models\SwaggerDemo.Models.csproj" />
  </ItemGroup>

</Project>

Konfiguracja Swaggera

Swagger odczytuje opisy właśnie z tego pliku XML. Trzeba mu to tylko powiedzieć. Robisz to, podczas konfigurowania Swaggera w kodzie przy konfiguracji serwisów:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);
});

Tutaj nie ma żadnej magii. Kluczową instrukcją jest IncludeXmlComments, gdzie w parametrze podaję pełną ścieżkę do utworzonej automatycznie dokumentacji xml. Czyli pobieram ścieżkę wykonywanego pliku, pobieram nazwę projektu i łączę to.

Teraz dokumentacja Swaggerowa wygląda już tak:

Opisywanie odpowiedzi

Swaggerowi możesz powiedzieć jeszcze, jakie endpoint generuje odpowiedzi i kiedy:

/// <summary>
/// Pobiera użytkownika po id
/// </summary>
/// <param name="id">Id użytkownika</param>
/// <response code="200">Zwraca znalezionego użytkownika</response>
/// <response code="404">Nie znaleziono takiego użytkownika</response>
/// <response code="500">Wewnętrzny błąd serwera</response>
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    User testUser = new User
    {
        Email = "test@example.com",
        Id = 1,
        Name = "Test"
    };

    return Ok(testUser);
}

Teraz strona Swaggera wygląda tak:

Patrząc na taką dokumentację nadal nie wiesz, jakie dane zwróci endpoint, jeśli zapytanie zakończy się sukcesem (kod 200). Możesz oczywiście wywołać tę końcówkę z poziomu Swaggera i otrzymasz wszystkie dane pobrane z API:

Jednak jest pewien sposób…

Opisywanie zwracanego modelu

Możesz pokazać Swaggerowi zwracany model:

[Route("api/[controller]")]
[ApiController]
[Produces("application/json")]
public class UsersController : ControllerBase
{
    /// <summary>
    /// Pobiera użytkownika po id
    /// </summary>
    /// <param name="id">Id użytkownika</param>
    /// <response code="200">Zwraca znalezionego użytkownika</response>
    /// <response code="404">Nie znaleziono takiego użytkownika</response>
    /// <response code="500">Wewnętrzny błąd serwera</response>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(User), 200)]
    public IActionResult GetById(int id)
    {
        User testUser = new User
        {
            Email = "test@example.com",
            Id = 1,
            Name = "Test"
        };

        return Ok(testUser);
    }
}

Tutaj zrobiłem dwie rzeczy. Na poziomie kontrolera powiedziałem, jaką odpowiedź kontroler zwraca (json). Co nie jest wymagane, ale lepiej wygląda w Swaggerze. No i oczywiście klienci Twojego API nie mają żadnych wątpliwości co do rodzaju zwrotki. W innym przypadku Swagger pokaże combobox z możliwością wyboru typu zwrotki.

Ważniejsza jednak rzecz jest na poziomie samego endpointa – atrybut ProducesResponseType. W parametrach pokazuję jaki typ jest zwracany przy jakim kodzie. Różne kody mogą zwracać różne typy modeli. Teraz Swagger wygląda tak:

Jak widzisz Swagger pokazuje teraz szablon zwracanego modelu.

Opisywanie pól modelu

W rzeczywistości modele bywają bardziej skomplikowane niż ten powyżej. A ich pola nie opisują się tak ładnie. Możemy Swaggerowi opisać dokładnie każde pole modelu. Jak? Również za pomocą komentarzy dokumentujących. Tym razem na poziomie konkretnego modelu:

public class User
{
    /// <summary>
    /// Id użytkownika
    /// </summary>
    public int Id { get; set; }
    /// <summary>
    /// Imię i nazwisko użytkownika. Uwaga! Pole może być puste
    /// </summary>
    public string Name { get; set; }
    /// <summary>
    /// E-mail użytkownika
    /// </summary>
    public string Email { get; set; }
    /// <summary>
    /// Hasło użytkownika. Zawsze puste, gdy pobieramy rekord.
    /// </summary>
    public string Password { get; set; }
}

Co się stanie, gdy odpalimy teraz Swaggera? Zupełnie nic 🙂

Dlatego też stworzyłem dwa projekty – jeden api, drugi dla modeli.

Przypominam, że Swagger opisy odczytuje z pliku dokumentacji (xml) tworzonego przez Visual Studio. O ile projekt API został ładnie ustawiony, to projekt z modelami nie ma takiej konfiguracji. Musimy ją więc dodać. Do projektu z modelami dodaj znane już elementy:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>
	<GenerateDocumentationFile>True</GenerateDocumentationFile>
	<NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

</Project>

Teraz jeszcze tylko musisz Swaggerowi powiedzieć, skąd ma ten dokument zaczytać. To też już robiliśmy. Podczas konfiguracji Swaggera trzeba tylko dodać kolejny plik:

builder.Services.AddSwaggerGen(o =>
{
    var assemblyName = Assembly.GetExecutingAssembly().GetName().Name + ".xml";
    var docFile = Path.Combine(AppContext.BaseDirectory, assemblyName);
    o.IncludeXmlComments(docFile);

    var modelsAssemblyName = typeof(User).Assembly.GetName().Name + ".xml";
    var modelsDocFile = Path.Combine(AppContext.BaseDirectory, modelsAssemblyName);
    o.IncludeXmlComments(modelsDocFile);
});

Tutaj, żeby nie wpisywać na sztywno nazwy projektu, posłużyłem się jakąś klasą, która występuje w projekcie z modelami. Traf chciał, że padło na klasę User. Generalnie wybrałem pierwszą lepszą. Chodziło o to, żeby refleksja zwróciła nazwę projektu. Reszta jest taka sama jak wyżej: IncludeXmlComments i wio.

Teraz Swagger wygląda tak:

Pamiętaj, że żeby zobaczyć opisy pól modelu, musisz kliknąć na Schema.

Swagger i wersjonowanie API

Często nasze API są wersjonowane. Swagger niestety nie ogarnia tego domyślnie. Jest kilka sposobów, żeby to zadziałało. Ja Ci pokażę jeden z nich – moim zdaniem najbardziej prawilny.

Jak to działa?

Słowem wstępu, Swagger działa tak, że używa mechanizmu dostarczanego przez Microsoft: EndpointsApiExplorer. Nie musisz tego dodawać ręcznie, to już dodaje Swagger podczas rejestrowania swoich serwisów.

ApiExplorer skanuje Twoje API i zwraca informacje o nim, a Swagger za jego pomocą buduje swoje pliki „map”.

Przy tym podejściu musisz zapewnić, że wersjonujesz API tak jak napisałem tutaj. Głównie chodzi o trzymanie kontrolerów dla różnych wersji w różnych namespace.

Krok 1 – stworzenie konwencji

Na początek musimy utworzyć konwencję, która odpowiednio pogrupuje kontrolery. Stwórz taką klasę:

public class GroupingByNamespaceConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        var controllerNamespace = controller.ControllerType.Namespace;
        var apiVersion = controllerNamespace.Split(".").Last().ToLower();
        if (!apiVersion.StartsWith("v")) 
            apiVersion = "v1";

        controller.ApiExplorer.GroupName = apiVersion;
    }
}

Zadaniem tej klasy jest odpowiednie zgrupowanie kontrolera (dodanie atrybutu GroupName). To grupowanie jest używane tylko przez ApiExplorer, czyli nie ma żadnego znaczenia dla działającego kodu. Zapamiętaj – tylko dla dokumentacji. Teraz trzeba tą konwencję zarejestrować podczas rejestracji serwisów:

builder.Services.AddControllers(o =>
{
    o.Conventions.Add(new GroupingByNamespaceConvention());
});

Krok 2 – konfiguracja dokumentacji Swaggera

Teraz musimy skonfigurować dokumentację dla każdej wersji. Robimy to podczas konfiguracji Swaggera:

builder.Services.AddSwaggerGen(o =>
{
    o.SwaggerDoc("v1", new OpenApiInfo
    {
        Title = "Wersja 1",
        Version = "v1"
    });

    o.SwaggerDoc("v2", new OpenApiInfo
    {
        Title = "Wersja 2",
        Version = "v2"
    });
});

Dodałem tutaj dwie wersje. Na koniec trzeba jeszcze je dodać podczas konfiguracji middleware:

app.UseSwagger();
app.UseSwaggerUI(o =>
{
    o.SwaggerEndpoint("/swagger/v1/swagger.json", "Wersja 1");
    o.SwaggerEndpoint("/swagger/v2/swagger.json", "Wersja 2");
});

Ważne, żeby nazwa przekazana w SwaggerEnpoint (Wersja1, Wersja2) była spójna z tytułem skonfigurowanym w SwaggerDoc.

Krok trzeci – aktualizacja kontrolerów

Na koniec już tylko musisz zaktualizować kontrolery, żeby powiedzieć im, którą wersję API wspierają. Prawdopodobnie będą sytuacje, że pomiędzy pierwszą i drugą wersją API zmieni Ci się tylko część kontrolerów. Wystarczy, że dodasz do nich atrybuty ApiVersion:

[Route("api/[controller]")]
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ItemsController : ControllerBase
{

}

Ułatwienia

Istnieje NuGet, który ułatwia konfigurowanie wersji w Swaggerze. Jednak komentarz autora (który przytaczam fragmentami niżej) mi daje taką myśl: „Wstrzymaj konie i poczekaj na nową wersję”. Ten NuGet to: Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer, jednak nie daj się zmylić – nie ma za wiele wspólnego aktualnie z Microsoftem:

Projekt rozpoczął się jako eksperyment myślowy jak wziąć pomysły stojące za wersjonowaniem API (zwłaszcza RESTowego) i zaimplementować je w praktyczny sposób (…). Rozwój (…) różnych projektów w Microsoft zajął około dwóch lat, ale w końcu powstał ogólny wzorzec i framework. 6 lat temu (2016 – przyp. tłumacz) przeniosłem to do społeczności open source, żeby rozwijać dalej i szczerze – dla mojego własnego egoistycznego użytku do projektów poza Microsoftem. To, że projekt stał się taki popularny, przerosło moje najśmielsze oczekiwania.

Decyzja, żeby przenieść projekt na Microsoftowy GitHub była głównie podyktowana open source’ową polityką firmy. Pomimo powszechnego przekonania, nie jestem i nigdy nie byłem częścią teamu od AspNetCore (…). Ten projekt nigdy w żaden sposób nie był oficjalnie wspierany. Pomimo, że pojawiło się kilku zewnętrznych kontrybutorów, głównie utrzymywałem go sam. W 2021 (…) zdecydowałem się opuścić Microsoft (…). Próbowałem zachować projekt i przekazać go, jednak pojawiło się wiele wyzwań. Zajęło to kilka miesięcy, ale ostatecznie uznałem, że najlepszym będzie przeniesienie projektu z organizacji Microsoft do .NET Foundation (…).

Pojawiło się kilka nowych problemów, m.in. nazwa, która wskazuje, że projekt jest zarządzany przez Microsoft (…). Chciałem zrobić fork projektu i rozpocząć nowy, jednak mogłoby to wprowadzić zamieszanie w społeczności (…).

Drugi problem to identyfikatory pakietów NuGet. Zasugerowano, że po prostu wyślę zawiadomienie, że identyfikator się zmieni. Jednak po 100 milionach pobrań stwierdziłem, że jest to niedopuszczalne. Zajęło to wiele miesięcy aby wyśledzić odpowiednich interesariuszy NuGet, aby rozpocząć proces, ale identyfikatory pakietów zostały teraz przeniesione do zespołu api-versioning z dotnetfoundation. Jeśli zastanawiasz się, dlaczego nie było żadnych aktualizacji od dłuższego czasu, to właśnie dlatego. Teraz mam trochę więcej kontroli nad pakietem i aktualizacje mogą znów się pojawiać. Jednak są z tym związane limity. Nowe funkcje nie mogą pojawiać się pod szyldem Microsoft(…). Zacząłem nawet prace nad nową wersją, która zaczynałaby się prefixem Asp.Versioning.* (…).

Krótko mówiąc – projekt powinien mieć jakieś aktualizacje do wersji 5.*. Jednak niczego więcej po nim nie można się spodziewać. A jego klon gdzieś kiedyś się pojawi.


To tyle, jeśli chodzi o dokumentowanie API. Jak widzisz, nie musi to być tak nudne, jak się wydaje. A i musisz przyznać, że dla klienta taka interaktywna dokumentacja ma dużo większą wartość niż tabelka w Wordzie. Spróbuj sam i zobacz, jak to jest. Co więcej, Swagger posługuje się standardem OpenAPI 3.0, więc możesz to sobie zaimportować nawet do PostMana! 🙂

Dzięki za przeczytanie artykułu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie podziel się w komentarzu. A jeśli znasz kogoś, komu ten artykuł się zdecydowanie przyda, udostępnij mu go 🙂

Podziel się artykułem na: