Piszemy klienta do WebAPI

Piszemy klienta do WebAPI

Wstęp

Często mówimy o tym, czym jest WebApi, jak je tworzyć, a jak nie. Ale jakoś nie mówimy o tym jak stworzyć dobrze fajnego klienta do tego WebAPI.

Mogłoby się zdawać, że wystarczy utworzyć instancję HttpClient i wywołać odpowiednią końcówkę. I czasem nawet można tak zrobić. Ale jeśli chcesz mieć naprawdę dobrego klienta do większego API niż tylko dwie końcówki, to ten artykuł pokaże Ci jak do tego podejść na konkretnym przykładzie.

Jest NuGetowa paczka – RestSharp. Jest to bardzo popularna darmowa biblioteka, która zdecydowanie ułatwia tworzenie klientów API. Jednak w tym artykule nie posłużymy się nią. Zrobimy coś sami. Potem sam zdecydujesz, czy wolisz tworzyć takie rozwiązania samodzielnie, czy z użyciem RestSharpa.

Przede wszystkim – WebAPI

Żeby klient API miał sens, musi przede wszystkim łączyć się z jakimś API. Dlatego też przygotowałem dość proste rozwiązanie, na którym będziemy pracować. Możesz je pobrać z GitHuba.

Uwaga! Nie zwracaj za bardzo uwagi na kod API – jest bardzo prosty, banalny i nie we wszystkich aspektach super poprawny. Nie zajmujemy się tutaj WebAPI, tylko klientem do API.

To Api trzyma dane w słowniku, to znaczy że po ponownym uruchomieniu, wszystkie dane znikną.

Api ma kilka końcówek, możesz sobie je zobaczyć, uruchamiając swaggera. Z grubsza to:

  • POST – /api/clients/all – pobiera listę klientów (dlaczego POST – o tym niżej)
  • POST – /api/clients – dodaje klienta
  • GET – /api/clients/{id} – pobiera klienta o konkretnym id
  • DELETE – /api/clients/{id} – usuwa klienta o konkretnym id
  • POST – /api/orders/all – pobiera zamówienia (dlaczego POST – o tym niżej)
  • POST – /api/orders – dodaje zamówienie
  • GET – /api/orders/client/{clientId} – pobiera zamówienia dla konkretnego klienta
  • GET – /api/orders/{id} – pobiera zamówienie o konkretnym id

Także mamy kilka końcówek podzielonych na dwa kontrolery.

Zaczynamy pisać klienta

OK, skoro już wiemy jak mniej więcej wygląda API, możemy utworzyć projekt, w którym napiszemy klienta. Niech to będzie zwykły projekt Class Library.

Model DTO

Najpierw musimy utworzyć modele DTO. DTO czyli Data Transfer Object – są to klasy, które przekazują dane między API, a klientem. Modele DTO mogą być jak najgłupsze się da. To po prostu worek na dane. Nic więcej.

Teraz możesz zapytać – po co tworzyć dodatkowy model, skoro mamy już dokładny model bazodanowy? Nie lepiej ten model bazodanowy z projektu WebApi przenieść do jakiegoś współdzielonego?

W tym konkretnym przypadku banalnej aplikacji – pewnie tak. Natomiast przy aplikacjach bardziej rozbudowanych przekazywanie danych za pomocą modeli bazodanowych może okazać się baaaardzo problematyczne. Sam wiele lat temu zrobiłem taki błąd. W pewnym momencie okazało się, że muszę stosować jakieś dziwne haki i czary, żeby to wszystko jakoś działało. Dlatego – stwórz osobny model DTO.

W przykładowej aplikacji są w projekcie Models. Modele DTO wyglądają prawie tak samo jak modele bazodanowe. Specjalnie dodałem do modeli bazodanowych jedną właściwość (IsDeleted), żeby je czymś rozróżnić.

Zwróć uwagę na dwie klasy:

GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

GetClientsResultDto:

public class GetClientsResultDto
{
    public IEnumerable<ClientDto> Data { get; init; }
    public int  Offset { get; init; }

    public GetClientsResultDto(IEnumerable<ClientDto> data, int offset)
    {
        Data = data;
        Offset = offset;
    }
}

W standardowym tutorialu tworzenia WebApi zobaczyłbyś, że gdy żądasz listy klientów, API zwraca po prostu listę klientów, np: IEnumerable<ClientDto>.

Jednak w prawdziwym świecie to może być za mało. Dlatego też stworzyłem dwie dodatkowe klasy:

  • GetClientsRequestDto – obiekt tej klasy będzie wysyłany wraz z żądaniem pobrania listy klientów
  • GetClientsResultDto – obiekt tej klasy będzie zwracany przez API zamiast zwykłej listy klientów.

Jak widzisz, te klasy zawierają w sobie informacje ograniczające ilość pobieranych danych. Jeśli miałbyś bazę z 10000 klientów i z jakiegoś powodu chciałbyś pobrać ich listę, to zupełnie bez sensu byłoby pobieranie wszystkich 10000 rekordów. To naprawdę sporo danych. Zamiast tego możesz pobierać te dane partiami i napisać jakiś prosty mechanizm paginacji. Do tego mogą właśnie służyć te dodatkowe klasy.

Analogicznie zrobiłem dla modelu OrderDto.

Abstrakcja

Skoro już mamy modele DTO, możemy pomyśleć o abstrakcji, która umożliwi nam testowanie klienta API.

Zgodnie z regułą pojedynczej odpowiedzialności (signle responsibility) klient API nie powinien być odpowiedzialny za wszystkie operacje związane z API. Ale powinien dać taką możliwość. Jak to osiągnąć? Poprzez dodatkowe klasy operacji. I tak będziemy mieć klasę odpowiedzialną za operacje na zamówieniach i drugą odpowiedzialną za klientów. Stwórzmy teraz takie abstrakcje:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

To jest interfejs, którego implementacja będzie odpowiedzialna za operacje na klientach. Analogicznie stworzymy drugi interfejs – do zamówień:

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

To są bardzo proste interfejsy i na pierwszy rzut oka wszystko jest ok. Ale co jeśli z WebApi otrzymasz jakiś konkretny błąd? Np. podczas dodawania nowego klienta mógłbyś otrzymać błąd w stylu: „Nazwa klienta jest za długa”. W taki sposób tego nie ogarniesz. Dlatego proponuję stworzyć dwie dodatkowe klasy, które będą przechowywały rezultat wywołania końcówki API:

public class BaseResponse
{
    public int StatusCode { get; init; }
    public bool IsSuccess { get { return StatusCode >= 200 && StatusCode <= 299 && string.IsNullOrWhitespace(ErrorMsg); } }
    public string ErrorMsg { get; init; }

    public BaseResponse(int statusCode = 200, string errMsg = "")
    {
        StatusCode = statusCode;
        ErrorMsg = errMsg;
    }
}

public class DataResponse<T> : BaseResponse
{
    public T Data { get; init; }

    public DataResponse(T data, int statusCode = 200, string errMsg = "")
        : base(statusCode, errMsg)
    {
        Data = data;
    }
}

Klasa BaseResponse i operacja zakończona poprawnie

Klasa BaseResponse będzie przechowywała kod odpowiedzi wraz z ewentualnym komunikatem o błędzie. Wg specyfikacji HTTP wszystkie kody od 200 do 299 włącznie oznaczają operację zakończoną poprawnie, dlatego też IsSuccess jest tak skonstruowane.

Teraz pojawia się pytanie – co oznacza „operacja zakończona poprawnie”? W kontekście WebApi zazwyczaj chodzi tutaj o to, że dane przesłane w żądaniu były prawidłowe, na serwerze nic się nie wywaliło, nie było problemu z autoryzacją i serwer odpowiedział prawidłowo. Jednak nie znaczy to, że operacja zakończyła się tak, jak byśmy sobie tego życzyli.

To trochę dziwnie brzmi, zatem pokażę Ci pewien przykład. Załóżmy, że chcesz pobrać klienta o ID = 5. Wg specyfikacji REST Api, jeśli taki klient nie istnieje, powinieneś otrzymać zwrotkę z kodem 404. Jednak błąd 404 oznacza również, że nie znaleziono określonej strony (końcówki API). Jest to pewien znany problem. Czasami się to tak zostawia, czasem można rozróżnić w taki sposób, że z WebAPI zwracamy kod 200 – operacja się powiodła, ale dołączamy informację o błędzie w odpowiedzi np: „Nie ma klienta o takim ID”.

To nam wszystko załatwia klasa BaseResponse.

Klasa DataResponse

Jak widzisz, DataResponse dziedziczy po BaseResponse. Jedyną różnicą jest to, że DataResponse przechowuje dodatkowo dane, które mogły przyjść w odpowiedzi. Teraz, mając takie klasy, możemy zmienić zwracany typ z interfejsów IClientOperations i IOrderOperations. Do tej pory wyglądało to tak:

public interface IClientOperations
{
    public Task<ClientDto> AddClient(ClientDto data);
    public Task<GetClientsResultDto> GetClients(GetClientsRequestDto data);
    public Task<ClientDto> GetClientById(int id);
    public Task<bool> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<OrderDto> AddOrder(OrderDto order);
    public Task<GetOrdersResultDto> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<GetOrdersResultDto> GetOrders(GetOrdersResultDto data);
    public Task<bool> DeleteOrder(int id);
}

A teraz będziemy mieli coś takiego:

public interface IClientOperations
{
    public Task<DataResponse<ClientDto>> AddClient(ClientDto data);
    public Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data);
    public Task<DataResponse<ClientDto>> GetClientById(int id);
    public Task<BaseResponse> DeleteClient(int id);
}

public interface IOrderOperations
{
    public Task<DataResponse<OrderDto>> AddOrder(OrderDto order);
    public Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data);
    public Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data);
    public Task<BaseResponse> DeleteOrder(int id);
}

Interfejs IApiClient

Skoro mamy już interfejsy dla poszczególnych operacji, możemy teraz napisać sobie interfejs do ApiClienta. I tutaj znów – ta abstrakcja nie jest konieczna. Jednak bez niej nie będziesz w stanie testować jednostkowo kodu, który używa klienta API.

Interfejs jest banalny:

public interface IApiClient
{
    IClientOperations ClientOperations { get; }
    IOrderOperations OrderOperations { get; }
}

Jak widzisz, klient API będzie dawał dostęp do poszczególnych operacji. To teraz zajmijmy się implementacją poszczególnych operacji, która zasadniczo będzie prosta.

Implementacja IClientOperations

Do komunikacji z WebApi wykorzystujemy HttpClient – dlatego też on musi znaleźć się w konstruktorze.

internal class ClientOperations : IClientOperations
{
    private readonly HttpClient _httpClient;

    public ClientOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<ClientDto>> AddClient(ClientDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients", data);
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteClient(int id)
    {
        var response = await _httpClient.DeleteAsync($"clients/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<ClientDto>> GetClientById(int id)
    {
        var response = await _httpClient.GetAsync($"clients/{id}");
        return await ResponseFactory.CreateDataResponse<ClientDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetClientsResultDto>> GetClients(GetClientsRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("clients/all", data);
        return await ResponseFactory.CreateDataResponse<GetClientsResultDto>(response, DefaultJsonSerializerOptions.Options);
    }
}

Dalej mamy implementację poszczególnych metod. Każda z nich jest oparta dokładnie na tej samej zasadzie:

  • wyślij żądanie na odpowiednią końcówkę
  • stwórz DataResponse/BaseResponse na podstawie otrzymanej odpowiedzi – HttpResponseMessage.

Zwróć uwagę tutaj na trzy rzeczy.

  1. Klasa DefaultJsonSerializerOptions – jest to klasa, która trzyma domyślne dla aplikacji ustawienia serializacji JSON. W naszej aplikacji nie chcemy, żeby serializacja brała pod uwagę wielkość znaków. Jeśliby brała wtedy taki obiekt:
public class MyClass
{
    public int Id { get; set; }
    public string Name { get; set; }
}

nie zostałby powiązany z takim jsonem:

{
  "id": 5,
  "name": "Adam"
}

Z tego powodu, że występuje różnica w wielkości znaków. Niestety domyślne ustwienia serializatora z Microsoft biorą pod uwagę wielkość znaków. My chcemy tego uniknąć, dlatego powstała klasa, która przechowuje odpowiednie opcje. Znajduje się w projekcie Common.

  1. ResponseFactory to pomocnicza klasa, która z odpowiedzi HttpRequestMessage tworzy interesujące nas obiekty DataResponse lub BaseResponse – omówimy ją za chwilę.
  2. Pobieranie danych za pomocą POST…

No właśnie, spójrz na metodę GetClients. Ona pobiera dane za pomocą POST, a nie GET. Dlaczego tak jest? Czyżby to jaka herezja?

Przyczyną jest obecność klasy GetClientsRequestDto:

public class GetClientsRequestDto
{
    public int Skip { get; set; }
    public int Take { get; set; }
}

Metoda GET nie może mieć żadnych danych w ciele żądania. Oczywiście w tym przypadku można by te dwie właściwości włączyć do query stringa, wywołując końcówkę np: api/clients/all?skip=0&take=10. Jeśli jednak masz sporo więcej do filtrowania, do tego jakieś sortowanie i inne rzeczy… lub z jakiegoś powodu takie dane nie powinny być w query stringu, to spokojnie możesz je wrzucić do POSTa. Nikt Cię za to nie wychłosta 😉 Co więcej – to jest normalną praktyką w niektórych WebAPI.

ResponseFactory

Jak już wspomniałem, klasa ResponseFactory jest odpowiedzialna za utworzenie BaseResponse/DataResponse na podstawie przekazanego HttpResponseMessage. Jej implementacja w naszym przykładzie wygląda tak:

internal static class ResponseFactory
{
    public static async Task<BaseResponse> CreateBaseResponse(HttpResponseMessage response)
    {
        if (response.IsSuccessStatusCode)
            return new BaseResponse((int)response.StatusCode);
        else
            return new BaseResponse((int)response.StatusCode, await GetErrorMsgFromResponse(response));
    }

    public static async Task<DataResponse<T>> CreateDataResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        if (response.IsSuccessStatusCode)
        {
            T data = await GetDataFromResponse<T>(response, jsonOptions);
            return new DataResponse<T>(data, (int)response.StatusCode);
        }
        else
        {
            return new DataResponse<T>(default(T), (int)response.StatusCode, await GetErrorMsgFromResponse(response));
        }
    }

    private static async Task<T> GetDataFromResponse<T>(HttpResponseMessage response, JsonSerializerOptions jsonOptions)
    {
        string content = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<T>(content, jsonOptions);
    }

    private static async Task<string> GetErrorMsgFromResponse(HttpResponseMessage response)
    {
        string result = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrEmpty(result))
            return response.ReasonPhrase;
        else
            return result;
    }
}

Nie ma tu niczego skomplikowanego. Wszystko sprowadza się do tego, że odczytuję dane z contentu odpowiedzi i deserializuję je do odpowiedniego obiektu. To wszystko. Jedyne, co może być ciekawe to metoda GetErrorMsgFromResponse, która ma zwrócić komunikat błędu. Zakładam, że jeśli błąd wystąpi, zostanie umieszczony po prostu jako content odpowiedzi – tak jest skonstruowane przykładowe WebAPI.

Implementacja IOrderOperations

Jest analogiczna jak IClientOperations, dlatego też nie będę jej omawiał. Kod wygląda tak:

internal class OrderOperations : IOrderOperations
{
    private readonly HttpClient _httpClient;

    public OrderOperations(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<DataResponse<OrderDto>> AddOrder(OrderDto order)
    {
        var response = await _httpClient.PostAsJsonAsync("orders", order);
        return await ResponseFactory.CreateDataResponse<OrderDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<BaseResponse> DeleteOrder(int id)
    {
        var response = await _httpClient.DeleteAsync($"orders/{id}");
        return await ResponseFactory.CreateBaseResponse(response);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrders(GetOrdersResultDto data)
    {
        var response = await _httpClient.PostAsJsonAsync("orders/all", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto>(response, DefaultJsonSerializerOptions.Options);
    }

    public async Task<DataResponse<GetOrdersResultDto>> GetOrdersForClient(int clientId, GetOrdersRequestDto data)
    {
        var response = await _httpClient.PostAsJsonAsync($"orders/client/{clientId}", data);
        return await ResponseFactory.CreateDataResponse<GetOrdersResultDto> (response, DefaultJsonSerializerOptions.Options);
    }
}

Implementacja ApiClient

OK, nadszedł wreszcie czas na napisanie implementacji głównego klienta API:

public class ApiClient : IApiClient
{
    public IClientOperations ClientOperations { get; private set; }
    public IOrderOperations OrderOperations { get; private set; }

    private readonly HttpClient _httpClient;

    public ApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        ClientOperations = new ClientOperations(_httpClient);
        OrderOperations = new OrderOperations(_httpClient);
    }
}

Tutaj HttpClient przychodzi z dependency injection. Następnie są tworzone odpowiednie obiekty – ClientOperations i OrderOperations, do których przekazujemy tego HttpClienta. Prawda, że proste?

HttpPipeline, czyli zupełnie nowy świat

Żeby klient API był wymuskany, można do niego dodać HttpPipeline. Pisałem o tym w tym artykule, więc nie będę się powtarzał. Zostawię Ci tylko zajawkę, że dzięki Http Pipeline, możesz zrobić zupełnie wszystko z żądaniem (zanim dotrze do celu) i odpowiedzią (zanim wróci do HttpClient). To zupełnie nowy świat możliwości. Przede wszystkim możesz automatycznie ustawiać wersję API, możesz odświeżać bearer token, możesz logować całe żądanie. Nic Cię tu nie ogranicza. Dlatego koniecznie przeczytaj ten artykuł, żeby mieć pełen obraz.

Przykładowe użycie

W repozytorium do tego artykułu jest umieszczony projekt WebApp – jest to bardzo prosta aplikacja RazorPages, które po krótce pokazuje użycie klienta.

UWAGA! Kod w aplikacji przykładowej jak i w WebApi jest podatny na różne rodzaje ataków. Dlatego nie stosuj takich „uproszczeń” w prawdziwym życiu. Różne ataki i jak się przed nimi chronić zostały opisane w tej książce.

W ramach ćwiczeń możesz spróbować zaimplementować w tym rozwiązaniu paginację, a także resztę operacji związanych z zamówieniami.


Dzięki za przeczytanie artykułu. Mam nadzieję, że teraz będziesz przykładał większą wagę do klientów API, które tworzysz i artykuł podpowiedział Ci jak to zrobić dobrze. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu 🙂

Obrazek artykułu: Server icons created by Freepik – Flaticon

Podziel się artykułem na:
Feature Flags – co to i po co?

Feature Flags – co to i po co?

Wstęp

Czasem bywa tak, że musimy wyłączyć pewne funkcje w aplikacji. Zazwyczaj dlatego, że nie są jeszcze gotowe/przetestowane w 100% i trudno by było opublikować aplikację bez nich.

W dzisiejszym artykule opowiem, jak podejść do tematu.

Czym są Feature flags?

To specjalny rodzaj flagi mówiącej o tym, czy dana funkcja może być używana. Najprostszą taką flagą będzie dyrektywa kompilatora, np:

#if IMPORT_DB
            DbImporter importer = new DbImporter();
            importer.Import();
#endif

Jeśli zdefiniowaliśmy flagę IMPORT_DB, wtedy ten kod się wykona. Tak samo jak wszystkie inne, które będą opatrzone dyrektywami #if..#endif. O ile dość łatwo to ogarnąć np. w C++, to w C# jest już ciężej z tego powodu, że każdy projekt ma swój własny zestaw definów. I wtedy trzeba pamiętać, żeby do każdego projektu dołączyć plik z tymi feature’ami.

Innym problemem może być (ale nie musi) to, że bez ponownej kompilacji nie odblokujemy funkcji programu. Czasem jest to pożądanie, czasem nie.

Dodatkowo, jakby nie patrzeć, takie dyrektywy zaciemniają w pewien sposób kod.

.NetCore ma jednak sprytny mechanizm do zarządzania flagami funkcji. Nazywa się to FeatureManager i za chwilę Ci go przedstawię.

Instalacja FeatureManager

Najpierw musisz pobrać sobie NuGet: Microsoft.FeatureManagement.AspNetCore

Teraz wystarczy już tylko zarejestrować serwisy z tej biblioteki. Robimy to oczywiście podczas rejestrowania wszystkich innych serwisów.

builder.Services.AddFeatureManagement();

Jeśli się przyjrzysz, to zobaczysz, że AddFeatureManagement ma dwie wersje. W drugiej możesz przekazać całą sekcję w konfiguracji, w której wyłączasz lub włączasz poszczególne funkcje (domyślnie są odczytywane z sekcji FeatureManagement).

Domyślne działanie jest takie, że FeatureManager odczytuje sobie poszczególne funkcje z appSettings.json z sekcji „FeatureManagement„. Oczywiście odczytuje to dokładnie tak samo jak wszystkie inne opcje programu. Czyli najpierw appSettings.json, appSettings.{Environment}.json, zmienne środowiskowe itd. Jeśli nie znasz tematu dokładnie, koniecznie przeczytaj ten artykuł (konfiguracja i opcje programu).

Tworzenie flag

Zrobimy sobie przykładowy projekt, który pokazuje działanie flag – symulator telewizora. Tym razem będzie to projekt MVC, żeby móc pokazać więcej rzeczy. Przykład możesz pobrać sobie z GitHuba.

Najpierw zatroszczmy się o flagi. Do appSettings dodaj taką sekcję:

"FeatureManagement": {
  "PowerControl": "true",
  "ChannelControl": "false",
  "VolumeControl": "false"
}

Zwróć uwagę, że sekcja nazywa się FeatureManagement. Tak jak już mówiłem, to z niej domyślnie są odczytywane wartości flag.

Zdefiniowaliśmy tutaj trzy flagi:

  • PowerControl – użytkownik może włączyć i wyłączyć telewizor
  • ChannelControl – użytkownik może przełączać kanały. Jak widzisz, w tym momencie flaga jest wyłączona, czyli pozbawiamy użytkownika tej opcji
  • VolumeControl – użytkownik może zmieniać głośność. Teraz też go pozbawiamy tej opcji.

Oczywiście będziemy musieli się posługiwać nazwami tych flag później w kodzie. Dlatego też powinniśmy je wyekstrahować albo do jakiś stałych, albo do jakiegoś enuma. Ja wybrałem stałe. Utwórz osobny plik do tego:

public static class FeatureFlags
{
    public const string PowerControl = "PowerControl";
    public const string ChannelControl = "ChannelControl";
    public const string VolumeControl = "VolumeControl";
}

Kontrola funkcji

Oczywiście nie można napisać mechanizmu, który automagicznie wyłączy lub włączy poszczególne funkcje. To musimy zrobić samemu. Możemy to zrobić na dwa sposoby. Spójrz na ten kod w widoku:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

Na początku wstrzykujemy IFeatureManager. Następnie sprawdzamy, czy konkretna flaga została włączona, używając metody IsEnabledAsync. W jej argumencie przekazujemy nazwę flagi.

Jeśli flaga jest włączona, pokazujemy dla niej funkcjonalność. Analogicznie teraz możemy zrobić dla pozostałych flag:

@using Microsoft.FeatureManagement
@inject IFeatureManager FeatureManager

<h1>SUPER TV!</h1>
<hr />

<h2>Pilot</h2>
<hr />
@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2">TV ON</button>
        <button class="btn btn-secondary col-3">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2"><strong>-</strong></button>
    </div>
}

Interfejs IFeatureManager możesz wstrzyknąć do dowolnej klasy i używać go też na backendzie.

Teraz dodajmy jakieś działanie do tych przycisków. Żeby to zrobić, umieścimy je wszystkie w formularzu, a każdy guzik będzie odnosił na inną końcówkę. Całość będzie wyglądała mniej więcej tak (fragmenty usunąłem dla lepszej czytelności):

<form method="post">
    @if (await FeatureManager.IsEnabledAsync(FeatureFlag.PowerControl.ToString()))
    {
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    }
//i dalej sprawdzenie innych flag
</form>

Zabezpieczanie back-endu

Jeśli na froncie nie ma konkretnej funkcji, nie znaczy że nie można jej wywołać na backendzie. Spójrz na ten kod:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [HttpPost]
    public IActionResult TvOn()
    {
        return View("Index");
    }

    [HttpPost]
    public IActionResult VolumeUp()
    {
        return View("Index");
    }

Jeśli teraz w jakiś sposób wywołamy końcówkę VolumeUp, no to stanie się coś złego. Więc z tej strony też powinniśmy się przed tym zabezpieczyć. FeatureManager daje nam bardzo fajny atrybut do tego FeatureGate:

[HttpPost]
[FeatureGate(FeatureFlags.VolumeControl)]
public IActionResult VolumeUp()
{
    return View("Index");
}

Jeśli teraz spróbujemy wywołać tę końcówkę, dostaniemy błąd 404 – strony nie znaleziono.

Za takie działanie jest odpowiedzialna domyślna implementacja interfejsu IDisabledFeaturesHandler. Oczywiście możesz sobie ją zmienić tak jak chcesz.

UWAGA! FeatureGate nie działa a RazorPages.

TagHelper

Jeśli nie podoba Ci się ta ifologia w widoku i widzisz tutaj szansę na użycie TagHelpers, dobra wiadomość jest taka, że Microsoft zrobił już to za Ciebie.

Spójrz jeszcze raz na kod widoku:

@if (await FeatureManager.IsEnabledAsync(FeatureFlags.PowerControl))
{
    <div class="row mb-2">
        <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
        <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.ChannelControl))
{
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
}

@if(await FeatureManager.IsEnabledAsync(FeatureFlags.VolumeControl))
{
    <div class="row mb-2">
        <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">VOLUME</span>
        <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
    </div>
}

Tego brzydala można zamienić na TagHelpery. I to jest drugi sposób ogarnięcia featerów na froncie.

Najpierw do _ViewImports.cshtml dodaj:

@addTagHelper *, Microsoft.FeatureManagement.AspNetCore

Teraz już możesz używać tag helpera feature:

<form method="post">
    <feature name="@FeatureFlags.PowerControl">
        <div class="row mb-2">
            <button class="btn btn-primary col-3 me-2" asp-action="TvOn">TV ON</button>
            <button class="btn btn-secondary col-3" asp-action="TvOff">TV OFF</button>
        </div>
    </feature>

    <feature name="@FeatureFlags.ChannelControl">
        <div class="row mb-2">
            <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">CHANNEL</span>
            <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
        </div>
    </feature>

    <feature name="@FeatureFlags.VolumeControl">
        <div class="row mb-2">
            <button class="btn btn-outline-info col-2 me-1" asp-action="VolumeUp"><strong>+</strong></button>
            <span class="col-2 my-auto text-center me-1">VOLUME</span>
            <button class="btn btn-outline-info col-2" asp-action="VolumeDown"><strong>-</strong></button>
        </div>
    </feature>
</form>

Przyznasz, że to wygląda zdecydowanie lepiej.

Co więcej, tag helpery dają Ci więcej możliwości niż tylko takie proste działanie. Możesz pokazać fragment, który będzie się pojawiał jeśli flaga będzie wyłączona, np:

<feature name="@FeatureFlags.ChannelControl">
    <div class="row mb-2">
        <button class="btn btn-info col-2 me-1" asp-action="ChannelUp"><strong>+</strong></button>
        <span class="col-2 my-auto text-center me-1">CHANNEL</span>
        <button class="btn btn-info col-2" asp-action="ChannelDown"><strong>-</strong></button>
    </div>
</feature>
<feature name="@FeatureFlags.ChannelControl" negate="true">
    <p>Zmiana kanałów będzie możliwa w przyszłości</p>
</feature>

Możesz też chcieć, żeby fragment kodu był widoczny tylko jeśli wszystkie lub kilka flag są włączone. Bardzo proszę:

<feature name="ChannelControl, VolumeControl" requirement="Any">
    <p>ChannelControl lub ValumeControl jest aktywne</p>
</feature>

<feature name="ChannelControl, VolumeControl" requirement="All">
    <p>ChannelControl i ValumeControl są aktywne</p>
</feature>

Wystarczy dodać nazwy tych flag do atrybutu name i posłużyć się atrybutem requirement. On może mieć dwie wartości – Any – jedna z flag musi być włączona; All – wszystkie flagi muszą być włączone.

Filtry i middleware

Jeśli używasz jakiegoś filtru (IAsyncActionFilter), który ma działać tylko gdy funkcja jest dostępna, możesz to zrobić w konfiguracji.

Dodaj ten filtr w nieco inny sposób niż standardowy:

builder.Services.AddControllersWithViews(o =>
{
    o.Filters.AddForFeature<VolumeFilter>(FeatureFlags.VolumeControl);
});

Zwróć uwagę, że nie rejestruję tutaj filtru z użyciem metody Add, tylko AddForFeature. W parametrze generycznym podaję typ filtru, a w środku nazwę flagi, z którą ten filtr ma być powiązany. W takim wypadku filtr zostanie odpalony tylko wtedy, jeśli flaga VolumeControl jest włączona.

Analogicznie można postąpić z middleware. Jeśli masz middleware, który ma być zależny od flagi, wystarczy że dodasz go w taki sposób zamiast standardowego:

app.UseMiddlewareForFeature<ChannelMiddleware>(FeatureFlags.ChannelControl);

To tyle jeśli chodzi o podstawy mechanizmu FeatureManager. To świetnie można połączyć z ustawieniami aplikacji na Azure – wtedy domyślnie stan flag odświeża się co 30 sekund. Ale to jest temat na inny artykuł, który powstanie.

Teraz dziękuję Ci za przeczytanie tego tekstu. Jeśli czegoś nie zrozumiałeś lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu.

Obrazek wyróżniający: Technologia zdjęcie utworzone przez pvproductions – pl.freepik.com

Podziel się artykułem na:
Autoryzacja oparta na zasobach

Autoryzacja oparta na zasobach

Wstęp – opisanie problemu

Często zdarza się, że użytkownik musi mieć pewne uprawnienia, żeby móc pracować z pewnymi zasobami. Żeby to nie brzmiało aż tak enigmatycznie, posłużę się przykładem.

Załóżmy, że jest aplikacja do fakturowania (np. Fakturownia, której sam używam :)). Załóżmy też, że aplikacja ma kilka ról – administrator, użytkownik i super admin.

Administrator – może tworzyć organizacje, dodawać do niej użytkowników i zarządzać wszystkimi rekordami w swojej organizacji. Użytkownik może tylko przeglądać i dodawać nowe faktury. A superadmin jest „ponad to” i może zupełnie wszystko. Super adminem byłby w tym przypadku właściciel takiego serwisu.

I teraz tak. Administrator zakłada konto „Piękny Lolo”. Dodaje rekordy związane z wydatkami (dla swojej organizacji), usuwa i git. Dodaje też użytkowników pod swoje konto.

Ale nagle rejestruje się nowy administrator (z innej organizacji) – zakłada konto: „Wąsaty Jan”. I teraz jakby wyglądała sytuacja, gdybyś posługiwał się autoryzacją opartą o role? Zarówno Wąsaty Jan, jak i Piękny Lolo mają uprawnienia administratora. Więc teoretycznie Wąsaty Jan może pracować na rekordach Pięknego i vice versa. Nie chcemy tego.

Trzeba zatem ograniczyć ich działanie tylko do ich „organizacji”. W innym przypadku mamy podatność bezpieczeństwa (jest to jedna z podatności, o których piszę w swojej książce – „Zabezpieczanie aplikacji internetowych”).

Tutaj z pomocą przychodzi tzw. autoryzacja oparta na zasobach (resource based authorization).

Przykładowy projekt

Dla ułatwienia posłużymy się prostszym problemem – zrobimy standardową aplikację do zadań. Nie zaciemni to obrazu, a postępowanie jest dokładnie takie samo.

Przygotowałem już gotowe rozwiązanie, które możesz pobrać z GitHuba: https://github.com/AdamJachocki/ResourceBasedAuth

Najpierw przyjrzyjmy się mu.

To jest zwykły projekt RazorPages z ustawionym uwierzytelnianiem (Authentication Type) na Individual Accounts. Dla ułatwienia wszystko zawarłem w jednym projekcie. Pamiętaj, że w prawdziwym życiu powinieneś rozdzielić ten projekt na kilka innych.

UWAGA! To jest bardzo prosta aplikacja bez żadnych walidacji. Pokazuje właściwie najprostszy uporządkowany kod, żeby bez sensu nie zaciemniać obrazu.

Potrafi utworzyć zadanie (TodoItem), zmodyfikować i usunąć je.

Zanim uruchomisz projekt, musisz utworzyć bazę danych. W katalogu z projektem uruchom polecenie:

dotnet ef database update

Namespacey projektu

Abstractions

Zawiera interfejs ITodoItemService, który jest wstrzykiwany do RazorPages. On obsługuje wszystkie operacje na bazie danych. Są dwa serwisy, które implementują ten interfejs: SecureTodoItemService – który pokazuje operowanie na zasobach w sposób bezpieczny, a także InsecureTodoItemService – ten pokazuje działania bez żadnych zabezpieczeń.

Domyślnie działającym jest InsecureTodoItemService. Możesz to zmienić w pliku Program.cs.

Areas

To domyślna obsługa .Net Identity – zakładanie kont, logowanie itp.

Data

Głównym jej elementem jest model bazodanowy TodoItem. Poza tym zawiera migracje EfCore, a także DbContext.

Pages

Zawiera strony i komponenty – zgodnie z nomenklaturą RazorPages

Services

Zawiera potrzebne serwisy.

Działanie niezabezpieczone

Spójrz na serwis InsecureTodoItemService. Jak widzisz nie ma on żadnych zabezpieczeń ani sprawdzeń. Przykładowa metoda usuwająca zadanie wygląda tak:

public async Task RemoveItem(int id)
{
    var model = new TodoItem { Id = id };
    _db.TodoItems.Remove(model);
    await _db.SaveChangesAsync();
}

To znaczy, że właściwie każdy, kto ma konto może usunąć dowolne itemy. Wystarczy poznać ID. Nie jest to, coś co byśmy chcieli uzyskać.

Więc zajmijmy się tym.

Zabezpieczamy program

Zabezpieczenie w tym przypadku polega na sprawdzeniu, czy użytkownik, który wykonuje operację ma prawo do wykonania tej operacji na danym zasobie. Czyli w przypadku tej aplikacji – czy jest właścicielem danego zasobu.

Oczywiście można to zrobić na kilka sposobów, jednak pokażę Ci tutaj standardowy mechanizm .NET, który to zadanie ułatwia.

Krok 1 – dodawanie wymagań

Pierwszy krok jest zarówno najprostszy, jak i najcięższy do zrozumienia. Musimy dodać wymaganie (requirement). To wymaganie musi zostać spełnione, żeby użytkownik mógł przeprowadzić operację.

To wymaganie może wyglądać tak:

public class TodoItemOwnerOrSuperAdminRequirement: IAuthorizationRequirement
{

}

Zapytasz się teraz – dlaczego ta klasa jest pusta? Jaki jest jej sens? To wytłumaczyć najtrudniej. Generalnie interfejs IAuthorizationRequirement nie ma w sobie żadnych metod, właściwości… zupełnie niczego. Jest pusty. Służy głównie tylko do opisania wymagania. Samego zaznaczenia odpowiedniej klasy. Oczywiście nikt Ci nie zabroni dodać do tej klasy jakiejś logiki. Możesz też ją wstrzykiwać do swoich serwisów.

Krok 2 – dodawanie AuthorizationHandler

Drugim krokiem jest dodanie handlera, który sprawdzi, czy użytkownik może wykonać daną operację. Prosty przykład w naszej aplikacji:

public class TodoItemAuthHandler : AuthorizationHandler<TodoItemOwnerOrSuperAdminRequirement, TodoItem>
{
    private readonly LoggedUserProvider _loggedUserProvider;

    public TodoItemAuthHandler(LoggedUserProvider loggedUserProvider)
    {
        _loggedUserProvider = loggedUserProvider;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, 
        TodoItemOwnerOrSuperAdminRequirement requirement, 
        TodoItem resource)
    {
        var loggedUser = await _loggedUserProvider.GetLoggedUser();
        if(resource.OwnerId == loggedUser.Id)
            context.Succeed(requirement);
        else
            context.Fail();
    }
}

Twoja klasa musi dziedziczyć po AuthorizationHandler. AuthorizationHandler jest abstrakcyjną klasą generyczną. W parametrach generycznych przyjmuje typ wymagania, a także typ resource’a. Jest jeszcze druga jej postać, która przyjmuje tylko typ wymagania.

Musisz przesłonić tylko jedną metodę – HandleRequirementAsync. W parametrze AuthorizationHandlerContext dostajesz m.in. zalogowanego użytkownika (ClaimsPrincipal). Ja się posługuję swoim serwisem LoggedUserProvider ze względu na prostotę (w przeciwnym razie musiałbym jakoś odczytywać i zapisywać claimsy). W parametrze dostajesz również obiekt, o który pytasz.

I jeśli spojrzysz teraz do ciała tej metody, zobaczysz że sprawdzam, czy zalogowany użytkownik jest właścicielem danego zasobu. Normalnie sprawdzałbym, czy zalogowany użytkownik jest superadminem lub właścicielem zasobu. Ze względu na prostotę, pominęliśmy tutaj aspekt ról i superadmina.

I teraz, jeśli użytkownik jest superadminem lub właścicielem zasobu, przekazuję do kontekstu sukces. W przeciwnym razie blokuję.

Krok 3 – użycie AuthorizationHandler

W pierwszej kolejności musimy zarejestrować naszą klasę AuthorizationHandler, żeby móc jej używać. Rejestrujemy to oczywiście podczas rejestracji serwisów:

services.AddScoped<IAuthorizationHandler, TodoItemAuthHandler>(); //może być jako singleton, jeśli Twój serwis nie wykorzystuje innych scoped serwisów

A potem już tylko mały zastrzyk do serwisu (plik SecuredTodoItemService.cs). Wstrzykujemy interfejs IAuthorizationService:

private readonly IAuthorizationService _authService;

public SecuredTodoItemService(IAuthorizationService authService)
{
    _authService = authService;
}

I spójrz na przykładowe użycie:

public async Task ModifyItem(TodoItem item)
{
    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, item,
        new TodoItemOwnerOrSuperAdminRequirement());
    if(authResult.Succeeded)
    {
        _db.TodoItems.Update(item);
        await _db.SaveChangesAsync();
    }
}

_httpContextAccessor to oczywiście wstrzyknięty IHttpContextAccessor, bo metoda AuthorizeAsync niestety wymaga od nas przekazania zalogowanego użytkownika (on się później znajdzie w kontekście HandleRequirementAsync z klasy dziedziczącej po AuthorizationHandler).

Generalnie klasa implementująca IAuthorizationService została zarejestrowana automatycznie podczas rejestrowania autoryzacji (AddAuthorization()). Gdy wywołujesz AuthorizeAsync, ona sprawdza typ zasobu i wymaganie, o które pytasz. Na tej podstawie wywołuje metodę HandleRequirementAsync z odpowiedniej klasy dziedziczącej po AuthorizationHandler. A ich możesz mieć wiele. Dla różnych zasobów i różnych wymagań.

Jaki z tego wniosek? Wystarczy, że napiszesz jedną klasę pod konkretny typ zasobu, który chcesz chronić.

Dodatkowe uproszczenie

Oczywiście to można jeszcze bardziej ukryć/uprościć, tworząc przykładową klasę ResourceGuard, np:

public class ResourceGuard
{
    private readonly IAuthorizationService _authService;
    private readonly IHttpContextAccessor _httpCtx;

    public ResourceGuard(IAuthorizationService authService, IHttpContextAccessor httpCtx)
    {
        _authService = authService;
        _httpCtx = httpCtx;
    }

    public async Task<AuthorizationResult> LoggedIsAuthorized<T>(object resource)
        where T: IAuthorizationRequirement, new()
    {
        var requirement = new T();
        var user = _httpCtx.HttpContext.User;

        //tu możesz sprawdzić, czy user jest super adminem albo pójść dalej:

        return await _authService.AuthorizeAsync(user, resource, requirement);
    }
}

Wykorzystanie takiej klasy byłoby już dużo łatwiejsze:

public async Task DeleteItem(TodoItem item)
{
    var authResult = await _guard.LoggedIsAuthorized<TodoItemOwnerOrSuperAdminRequirement>(item);
    if (!authResult.Succeeded)
        return;
    else
    {
        //todo: usuń
    }
}

Gdzie wstrzykujesz już tylko ResourceGuard'a.

Moim zdaniem, jeśli masz dużo zasobów do chronienia, pomysł z ResourceGuardem jest lepszy, ale to oczywiście wszystko zależy od konkretnego problemu.

Pobieranie danych

A jak sprawdzić autoryzację przy pobieraniu danych? Tutaj trzeba odwrócić kolejność. Do tej pory najpierw sprawdzaliśmy autoryzację, a potem robiliśmy operacje na danych.

W przypadku pobierania musisz najpierw pobrać żądane dane, a dopiero potem sprawdzić autoryzację, np.:

public async Task<TodoItem> GetItemById(int id)
{
    var result = await _db.TodoItems.SingleOrDefaultAsync(x => x.Id == id);
    if (result == null)
        return null;

    var authResult = await _authService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, result,
        new TodoItemOwnerOrSuperAdminRequirement());
    if (authResult.Succeeded)
        return result;
    else
        return null;
}

Jeśli musisz pobrać całą listę, to być może będziesz musiał sprawdzić każdy rekord z osobna. I potem w zależności od analizy biznesowej – albo zwracasz tylko te rekordy, do których użytkownik może mieć dostęp, albo gdy nie ma dostępu przynajmniej do jednego – nie zwracasz niczego.

UWAGA!

Zwróć uwagę, że w przykładowym projekcie, jeśli użytkownik nie ma uprawnień do wykonania operacji to albo jej nie wykonuję, albo zwracam null. W rzeczywistym projekcie dobrze jednak jest w jakiś sposób poinformować kontroler, żeby odpowiedział błędem 403 - Forbidden.


Dzięki za przeczytanie artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś błąd w tekście, koniecznie daj znać w komentarzu 🙂

Daj znać w komentarzu jaką formę artykułów wolisz – taką z gotowym projektem na GitHub, który omawiam – tak jak tutaj, czy klasyczną, w której tworzymy projekt od nowa z pominięciem GitHuba.

Obrazek z artykułu: Tkanina plik wektorowy utworzone przez storyset – pl.freepik.com

Podziel się artykułem na: