Dodawanie kontrolera do osobnej biblioteki może być użyteczne w przypadku, gdy na przykład tworzysz plugin lub system, który korzysta z pluginów. Lub z jakiegoś jeszcze innego powodu chcesz wydzielić część kontrolerów do innego projektu. W .NET robi się to bardzo prosto.
Krok po kroku
Zakładam, że masz już istniejącą solucję z kontrolerami API, czy też MVC.
Dodaj kolejny projekt Class Library do solucji, jeśli jeszcze go nie masz.
Doinstaluj do niego paczkę NuGet: Microsoft.AspNetCore.App
Podczas rejestracji serwisów dodaj:
services.AddMvc().AddApplicationPart(assembly);
Zmienna assembly to oczywiście Twoje assembly z ClassLibrary, w którym masz kontrolery. Możesz to pobrać na kilka sposobów. Jeśli taką rejestrację przeprowadzasz z jakiejś extension method w swojej ClassLibrary, np:
public static class ServiceCollectionExtensions
{
public static IServiceCollection(this IServiceCollection services)
{
services.AddMvc().AddApplicationPart(Assembly.GetExecutingAssembly());
}
}
Jeśli jednak rejestrację przeprowadzasz z jakiegoś powodu z głównej aplikacji, to najprościej pobrać Assembly po konkretnej klasie.
Załóżmy, że Twój kontroler mieści się w takiej klasie:
namespace API.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
//
}
}
Wtedy wystarczy pobrać assembly z tej właśnie klasy:
var assembly = typeof(API.Controllers.AccountController).Assembly;
services.AddMvc().AddApplicationPart(assembly);
To wszystko. Ta prosta „sztuczka” może sprawić, że Twój projekt stanie się bardziej czytelny i bardziej modularny.
Dzięki za przeczytanie artykułu. Jeśli znalazłeś jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu. Jeśli uważasz, że ta „sztuczka” jest super przydatna i ma też inne zastosowania, to też się podziel 🙂
Ostatnio miałem taką sytuację, że potrzebowałem zalogować się na pewien serwis z użyciem przeglądarki i potem na nim pracować PostMan’em. Było to niebywale upierdliwe, dlatego też zadałem pytanie: "Hej, czy nie można by uruchomić przeglądarki z PostMana?" Okazuje się, że można. I można duuuużo więcej.
Chromedriver
Przede wszystkim musisz zainstalować Chromedriver. To taki serwer działający lokalnie na określonym porcie (domyślnie 9515). Gdy otrzyma odpowiedni request, uruchamia przeglądarkę (Chrome), ale może też robić z nią różne dziwne rzeczy. Np. zamknąć. Albo pobrać z niej dane.
Ten serwer implementuje standard W3C WebDriver. Warto zajrzeć na tę stronę choćby po to, żeby zapoznać się ze wszystkimi możliwościami.
Możesz go zainstalować na kilka sposobów:
przez stronę z https://chromedriver.chromium.org/ – to jest zwykły plik exe bez żadnego instalatora. Więc, żeby go wygodnie używać, powinieneś dodać go gdzieś do ścieżki PATH.
z użyciem NPM – to coś w rodzaju managera pakietów dla Node.js. Taki NuGet. Jak sprawdzić, czy masz go zainstalowanego? Otwórz konsolę i wpisz npm --version. Jeśli masz zainstalowane, zobaczysz numer wersji. Jeśli nie, to konsola wybuchnie 🙂 Żeby go zainstalować, musisz zainstalować Node.js (możesz to zrobić też z poziomu instalatora Visual Studio). Następnie wystarczy nadusić:
To polecenie zainstaluje ChromeDriver globalnie i od razu je uruchomi. Później, żeby uruchomić ChromeDriver ponownie (np. po restarcie systemu) wystarczy, że wpiszesz do konsoli samo chromedriver.
z użyciem chocolatey – to taki manager pakietów dla Windows. Istnieje kilka innych, np. winget, jednak winget nie ma tego pakietu. Jeśli chcesz ten serwer instalować w taki sposób, przede wszystkim musisz uruchomić konsolę JAKO ADMINISTRATOR, a następnie wpisz: choco install chromedriver. Potem już tylko uruchom, wpisując chromedriver.
Moim subiektywnym zdaniem, instalacja za pomocą npm jest najprostsza. Generalnie instalacja tego za pomocą managera pakietów ma tą przewagę, że one od razu „rejestrują” ten chromedriver globalnie w systemie. Tzn., że żeby go uruchomić wystarczy, że w konsoli wpiszesz chromedriver.
Postman i przeglądarka
Uruchomienie przeglądarki
Gdy już masz URUCHOMIONY chromeDriver (upewnij się, że działa – blokuje okno konsoli), możesz uruchomić PostMana i wysłać swój pierwszy request:
Tak wygląda uruchamianie ChromeDriver
Wyślij metodą POST na adres http://localhost:9515/session takiego jsona:
Najważniejsze tutaj to sessionId na samym końcu odpowiedzi. To jest identyfikator sesji, którym będziesz się posługiwał w kolejnych żądaniach do tej przeglądarki (tak, możesz mieć wiele sesji i wiele okien przeglądarki).
Wywołanie adresu w przeglądarce
Oczywiście do przeglądarki możesz wysłać dowolny adres, który ona otworzy. Służy do tego końcówka url.
Wyślij POST na http://localhost:9515/session/:sessionId/url, gdzie :sessionId to oczywiście ID konkretnej sesji. W BODY musisz przekazać parametr o nazwie url w postaci json:
{
"url": "https://www.google.pl"
}
Pobranie adresu z przeglądarki
A teraz pobierzemy adres z paska adresu w otwartej przeglądarce.
Wyślij żądanie metodą GET na ten sam adres, co wyżej: http://localhost:9515/session/:sessionId/url, gdzie :sessionId to oczywiście ID konkretnej sesji. W odpowiedzi dostaniesz takiego JSONa:
{
"value": "https://www.google.pl/"
}
Możliwości
Możliwości są właściwie ograniczone tylko wyobraźnią. W specyfikacji WebDriver jest wszystko opisane. Możesz minimalizować okno, zamykać, tworzyć nowe karty, pobierać HTML, klikać konkretne elementy na stronie. Właściwie wszystko. I to za pomocą odpowiednich requestów opisanych w ww. specyfikacji. Na koniec zostawiam wisienkę na torcie.
PostMan, przeglądarka i OAuth2
Wyobraź sobie, że pracujesz na WebAPI, które korzysta z innego API. Twoje WebApi wymaga logowania za pomocą OAuth2, np. w użyciem konta Microsoft. W Postmanie taki flow można bardzo łatwo osiągnąć, tworząc kolekcję i konfigurując w niej autoryzację.
Jednak problem jest inny – dodatkowe API, które jest wywoływane przez Twoje API potrzebuje drugiej autoryzacji. I tu wchodzimy w konkretny przypadek użycia. Żeby móc wywołać jakąkolwiek końcówkę z dodatkowego API, musisz najpierw wywołać stronę logowania, pobrać z niej AuthCode, następnie wysłać ten AuthCode do konkretnego endpointa, żeby otrzymać authorization token i refresh token. I dopiero mając te tokeny możesz bawić się z tym drugim API. Najzwyklejszy flow OAuth2.
I niby wszystko masz już podane na tacy – wiesz jak wywołać konkretne requesty do przeglądarki. Problem polega na tym, że skrypty w PostManie działają asynchronicznie. W rezultacie, skończysz z asynchronicznym piekłem – czyli zagnieżdżeniami, gdzie kolejny request musisz wywołać na callback poprzedniego. A na koniec może się okazać, że i tak wysłałeś żądanie tokenów za wcześnie. Pokażę Ci teraz, jak to zrobić w skrypcie PostMana.
Zaznaczam, że nie lubię, nie umiem i nawet chyba nie szanuję JavaScriptu, więc mój kod może nie być idealny, ale działa 😉
Synchroniczne requesty
Tak naprawdę wszystkie requesty wciąż będą asynchroniczne, ale zadziała tutaj słówko await dokładnie w taki sam sposób, jakby się można było tego spodziewać w .NET.
Niemniej jednak na początku musimy napisać kilka helperów.
Aha, jeśli nie wiesz, jak się pisze skrypty w Postmanie, to po prostu kliknij na kartę Pre-Request Script w oknie żądania. Ten skrypt uruchomi się tuż przed wysłaniem faktycznego żądania.
Na początek trzeba zaimportować sdk PostMana, którego będziemy używać:
const sdk = require('postman-collection');
Teraz napiszemy sobie dwie pierwsze metody: waitUntilDone i sleep. Można by to przetłumaczyć na .Net jako Task.When() i Task.Delay():
function waitUntilDone(promise) {
const wait = setInterval(() => {}, 300000);
promise.finally(() => clearInterval(wait));
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Teraz metoda, która służy do wysyłania requestów. Oczywiście PostMan w swoim obiekcie pm ma taką metodę jak sendRequest. Jednak nasz sendRequest będzie mógł być awaitowany i zwróci wynik w returnie, a nie w callbacku.
Teraz napiszemy funkcję sleep, którą można by przetłumaczyć na .NET jako Task.Delay:
function sendRequest(req) {
return new Promise((resolve, reject) => {
pm.sendRequest(req, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
})
});
}
Zwróć uwagę, że na początku tej funkcji występuje słówko async. Poza tym nic się tu ciekawego nie dzieje. Po prostu wysyłamy żądanie otwarcia przeglądarki dokładnie takie samo, jak wyżej w tym artykule opisałem.
To teraz funkcja do nawigowania – przechodzimy na konkretną stronę w otwartej przeglądarce.
async function sendNavigateRequest(sessionId)
{
let urlParam = "https://www.google.pl";
let bodyParam = {
"url": urlParam
};
let navigateRequest = {
url: "http://localhost:9515/session/" + sessionId + "/url",
method: "POST",
body: {
mode: "raw",
raw: JSON.stringify(bodyParam)
}
}
return await sendRequest(navigateRequest);
}
Tutaj też wysyłamy prosty request jak wyżej.
Teraz będzie trochę magii. Funkcja, która pobierze wartość z query parameter w aktualnym adresie z przeglądarki:
async function getCallbackAuthCode(sessionId)
{
let getAddressRequest = {
url: "http://localhost:9515/session/" + sessionId + "/url",
method: "GET"
}
let authCode = "";
while(authCode == "")
{
await sleep(3000);
const response = await sendRequest(getAddressRequest);
let data = response.json();
if(data.value.includes("code="))
{
const url = new sdk.Url(data.value);
authCode = url.query.find((i) => i.key == "code");
}
}
return authCode;
}
Tutaj zasadniczo chodzi o to:
pobierz adres z przeglądarki (wysłanie getAddressRequest)
jeśli w adresie znajduje się string "code=", wtedy odczytaj query parameter o nazwie „code” i zakończ funkcję
jeśli nie ma, to poczekaj 3 sekundy i zobacz znów.
Po co to czekanie? Pamiętaj jak działa OAuth2. Otwierasz przeglądarkę – za pomocą skryptu. Przechodzisz na stronę logowania – za pomocą skryptu. Logujesz się, podając swoje dane – ręcznie. Po zalogowaniu zostajesz przekierowany na jakiegoś callbacka, w którym masz Auth Code. W naszym wypadku założyłem, że ten adres przekierowania będzie zawierał AuthCode w parametrze code.
No i to czekanie to czas pomiędzy tym jak otworzysz stronę logowania i jak zostaniesz przekierowany z poprawnym AuthCodem. Musimy poczekać na to przekierowanie.
Teraz już właściwie koniec – piszemy główny program:
async function main() {
//otwieram przeglądarkę
let result = await sendOpenBrowserRequest();
const openBrowserResult = result.json();
//sprawdzam, czy się udało
if(openBrowserResult.value.hasOwnProperty('error'))
{
console.error("Nie można było otworzyć przeglądarki");
return;
}
//przechodzę na stronę logowania
let navigateResult = await sendNavigateRequest(openBrowserResult.value.sessionId);
//czekam na authcode i pobieram go z adresu
let authCode = await getCallbackAuthCode(openBrowserResult.value.sessionId);
console.log("Auth code: " + authCode.value);
//ustawiam zmienną w parametrze url żądania
pm.variables.set('authcode', authCode.value);
}
//uruchamiam główną funkcję i czekam na jej zakończenie
util.waitUntilDone(main().catch(console.error));
I teraz cały pic polega na tym, że żądanie z PostMana (to główne, które wywołujesz guzikiem SEND) wyjdzie dopiero wtedy, gdy te wszystkie inne procesy się zakończą. Czyli zmienna authcode zostanie ustawiona. Oto cały kod:
const sdk = require('postman-collection');
function waitUntilDone(promise) {
const wait = setInterval(() => {}, 300000);
promise.finally(() => clearInterval(wait));
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function sendRequest(req) {
return new Promise((resolve, reject) => {
pm.sendRequest(req, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
})
});
}
async function sendOpenBrowserRequest()
{
let openBrowserRequest = {
url: "http://localhost:9515/session",
method: "POST",
body: {
mode: "raw",
raw: JSON.stringify({
"capabilities": {
"alwaysMatch": {
"browserName": "chrome",
"acceptInsecureCerts": true,
"goog:chromeOptions": {
"w3c": true
}
},
"firstMatch": [
{}
]
},
"desiredCapabilities": {
"browserName": "chrome",
"acceptInsecureCerts": true
}
})
}
};
return await sendRequest(openBrowserRequest);
}
async function sendNavigateRequest(sessionId)
{
let urlParam = `https://model.simplysign.webnotarius.pl/idp/oauth2.0/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://api.dev.inperly.cloud/providers/asseco/simplysign/callback&state=${backendId}`;
let bodyParam = {
"url": urlParam
};
let navigateRequest = {
url: "http://localhost:9515/session/" + sessionId + "/url",
method: "POST",
body: {
mode: "raw",
raw: JSON.stringify(bodyParam)
}
}
return await sendRequest(navigateRequest);
}
async function getCallbackAuthCode(sessionId)
{
let getAddressRequest = {
url: "http://localhost:9515/session/" + sessionId + "/url",
method: "GET"
}
let authCode = "";
while(authCode == "")
{
await sleep(3000);
const response = await sendRequest(getAddressRequest);
let data = response.json();
if(data.value.includes("code="))
{
const url = new sdk.Url(data.value);
authCode = url.query.find((i) => i.key == "code");
}
}
return authCode;
}
async function main() {
//otwieram przeglądarkę
let result = await sendOpenBrowserRequest();
const openBrowserResult = result.json();
//sprawdzam, czy się udało
if(openBrowserResult.value.hasOwnProperty('error'))
{
console.error("Nie można było otworzyć przeglądarki");
return;
}
//przechodzę na stronę logowania
let navigateResult = await sendNavigateRequest(openBrowserResult.value.sessionId);
//czekam na authcode i pobieram go z adresu
let authCode = await getCallbackAuthCode(openBrowserResult.value.sessionId);
console.log("Auth code: " + authCode.value);
//ustawiam zmienną w parametrze url żądania
pm.variables.set('authcode', authCode.value);
}
//uruchamiam główną funkcję i czekam na jej zakończenie
util.waitUntilDone(main().catch(console.error));
Testujemy
Możesz to bardzo prosto przetestować. Stwórz żądanie w PostManie, które wywoła taki adres metodą GET:
https://postman-echo.com/get?mycode={{authcode}}
Wywołujemy tutaj specjalne żądanie z usług PostMana, które jest takim „echo”. Tzn. w odpowiedzi dostaniemy m.in. przekazane parametry.
W skrypcie zmienną authcode ustawiamy w tym miejscu: pm.variables.set('authcode', authCode.value);
Więc jeśli teraz uruchomisz żądanie z pełnym skryptem, to na początku zostanie otwarte okno przeglądarki, następnie przeglądarka przejdzie na adres https://www.google.pl i będzie czekać. W tym momencie jesteśmy w funkcji getCallbackAuthCode i skrypt czeka, aż w pasku adresu pojawi się ciąg: „code=„. Możesz go teraz tam dopisać do istniejącego adresu ręcznie, np: https://www.google.pl?code=siema
Teraz, gdy wciśniesz Enter, skrypt rozpozna, że jest tam ciąg „code=" i pobierze jego wartość. Ta wartość zostanie ustawiona jako wartość zmiennej authcode w Twoim żądaniu i ostatecznie PostMan wyśle takie żądanie:
To tyle jeśli chodzi o pobieranie danych z przeglądarki przez PostMana. Jeśli temat Cię zainteresował lub potrzebujesz czegoś więcej, koniecznie sprawdź dokumentację standardu WebDriver, z której dowiesz się ile jeszcze rzeczy i w jaki sposób możesz zrobić z przeglądarką za pomocą HTTP.
Dzięki za przeczytanie artykułu. Jeśli znalazłeś w nim jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
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.
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 odpowiedzi HttpRequestMessage tworzy interesujące nas obiekty DataResponse lub BaseResponse – 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 🙂
Jak zapewne wiesz, sercem .Net jest middleware pipeline. To sprawia, że możemy sobie napisać dowolny komponent i wpiąć go w łańcuch przetwarzania żądania.
Jednak HttpClient też posiada swój „rurociąg”. Możesz napisać małe komponenty, które w odpowiedni sposób będą procesować żądanie. Dzięki temu możemy osiągnąć naprawdę bardzo fajne efekty, np. zautomatyzować wersjonowanie żądań albo odnawianie bearer tokena. W tym artykule pokażę Ci oba takie przykłady.
Czym jest HttpMessageHandler?
HttpMessageHandler zajmuje się najbardziej podstawową obsługą komunikatów. Każdy HttpClient zawiera HttpMessageHandler (domyślnie HttpClientHandler).
Czyli wyobraź sobie, jakby HttpClient był panem, który każe wysłać wiadomość, a MessageHandler był takim gołębiem pocztowym, który dalej się tym już zajmuje. To jest jednak klasa abstrakcyjna, po której dziedziczy kilka innych, m.in. DelegatingHandler, jak też wspomniany HttpClientHandler – gołąb pocztowy.
Czym jest DelegatingHandler?
I tu dochodzimy do sedna. DelegatingHandler to klasa, którą możesz wpiąć w łańcuch handlerów. Co więcej, każdy DelegatingHandler ma pod spodem HttpClientHandlera, który służy do faktycznego, fizycznego przekazania wiadomości.
To brzmi trochę jak czeskie kino, więc wejdźmy w przykład. Stwórzmy handler, który zapisze w logach wiadomość, że odbywa się żądanie.
Jak widać na załączonym obrazku, trzeba zrobić 3 rzeczy:
napisać klasę dziedziczącą po DelegatingHandler
przeciążyć metodę Send/SendAsync
wywołać Send/SendAsync z klasy bazowej.
Dopiero wywołanie SendAsync z klasy bazowej pchnie cały request do Internetów. Czyli, jeśli byś chciał, mógłbyś napisać takiego handlera, który niczego nie przepuści i zwróci jakiś ResponseMessage z dowolnym kodem.
Mając takiego handlera, musimy go wpiąć do pipeline’a http. Można to zrobić na dwa sposoby.
Rejestracja Handlera
Generalnie rejestrujemy go podczas rejestrowania serwisów. Konkretnie – podczas rejestrowania HttpClienta. O prawidłowym użyciu HttpClienta i tworzeniu go przez fabrykę, pisałem w tym artykule.
Najpierw rejestrujemy naszego handlera w DependencyInjection. Potem rejestrujemy HttpClient i dodajemy do niego naszego handlera przez metodę AddHttpMessageHandler. Pamiętaj tylko, żeby doinstalować z NuGeta paczkę Microsoft.Extensions.Http.
Tutaj możesz zarejestrować cały łańcuch takich handlerów. Oczywiście kolejność jest istotna. Handlery będą się wykonywały w kolejności ich rejestracji.
Jest jeszcze druga metoda. Jeśli z jakiegoś powodu tworzysz HttpClient ręcznie, możesz też utworzyć instancje swoich handlerów i umieścić jednego w drugim – jak w ruskiej babie, np:
services.AddScoped<LoggerHandler>();
services.AddScoped(sp =>
{
var loggerHandler = sp.GetRequiredService<LoggerHandler>();
var otherHandler = new OtherHandler();
loggerHandler.InnerHandler = otherHandler;
otherHandler.InnerHandler = new HttpClientHandler();
var client = new HttpClient(loggerHandler);
return client;
});
Spójrz, co ja tutaj robię. Na początku rejestruję LoggerHandler w dependency injection. Nie muszę tego oczywiście robić, ale mogę 🙂