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ówGetClientsResultDto
– 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.
- 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
.
-
ResponseFactory
to pomocnicza klasa, która z odpowiedziHttpRequestMessage
tworzy interesujące nas obiektyDataResponse
lubBaseResponse
– omówimy ją za chwilę. - 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