Projektowanie REST API - okładka

Projektowanie REST API

Data publikacji Kategorie Backend, Czysty kod

Wpis jest kontynuacją wstępu do REST API , który cieszy się sporą popularnością. Jeśli nie wiesz czym jest REST API, to serdecznie zachęcam cię do zapoznania się z moim poprzednim wpisem. Następnie wróć do lektury tego artykułu.

Tym razem nie skupię się na teoretycznych podstawach lecz przedstawię Ci szereg dobrych praktyk i zasad projektowania REST API. Przedstawione w artykule rady pozwolą Ci budować API, które będą intuicyjne i proste w obsłudze.

Każda zmiana kosztuje

Myślę, że z powyższym stwierdzeniem zgodzi się każdy. Oczywiście wysokość kosztu zmiany zależy od wielu czynników. Przykładowe czynniki to:

  • Ilość użytkowników korzystających z API.
  • Skala zmiany – małym kosztem będzie dodanie nowego pola do zwracanego obiektu w jednym endpoincie. Zupełnie innym kosztem będzie np. przeprojektowanie wszystkich ścieżek w API.
  • Czas wymagany do wdrożenia zmiany. Składają się na to: zmiany w kodzie, testy oraz wdrożenie. Warto dodać, że czas niekoniecznie musi być skorelowany ze skalą zmiany. W źle zarządzanych systemach, nawet pozornie małe zmiany mogą oznaczać wiele godzin pracy.
  • Konieczność utrzymania kompatybilności wstecznej lub konieczność rozwoju kilku równoległych wersji API.
  • Koszt alternatywny – jaki będzie koszt jeśli zmiany nie zostaną wdrożone?

W pewnych przypadkach zmiany są nieuniknione. Jednakże, wprowadzanie zmian na etapie projektowania jest zdecydowanie tańsze w implementacji niż wdrażanie zmian do gotowego API.

Przede wszystkim API powinno być spójne

Niezależnie od konwencji jaka została przyjęta, projektowane API powinno być spójne. Na etapie projektowania API należy zastanowić się jakie konwencje nazewnictwa przyjmiesz. Zasoby powinny mieć spójny sposób zapisu (np. camelCase lub snake_case) oraz być wyrażone w takiej samej liczbie (pojedyncza lub mnoga). W całym API podobne akcje powinny być możliwe do wykonania w taki sam sposób. Przykładowo, jeśli do update’u jakiegoś zasobu wszędzie wykorzystana jest metoda PUT, wykorzystanie metody POST/PATCH w jednej z metod jako wyjątku będzie nieintuicyjne.

Trzymaj się standardów

Mówiąc o projektowaniu REST API, nie można nie wspomnieć o protokole HTTP. We wstępie do REST API nakreśliłem już czym jest HTTP oraz do czego służy. Nie napisałem tam jednak czy jest protokół. Otóż protokół, najprościej mówiąc, jest zbiorem reguł (standardem), którego należy się trzymać. W związku z tym, projektując API oparte na protokole HTTP, powinno się trzymać wytycznych wyznaczonych przez ten protokół.

Jedną z wytycznych wyznaczonych przez HTTP jest stosowanie odpowiednich kodów odpowiedzi. Przykładowo:

  • 200 – Serwer zakończył akcję pomyślnie.
  • 201 – Utworzono zasób na serwerze.
  • 404 – Nie znaleziono zasobu.
  • 500 – Wewnętrzny błąd serwera.

Pełną listę kodów HTTP wraz z ich przeznaczeniem znajdziesz w zasobach MDN. Zachęcam do wystrzegania się potworków pokroju zwracania kodu 200 dla każdej odpowiedzi i przekazywania właściwego kodu błędu w zwrotce:

// Tak nie rób!
{
    "statusCode": 500
    "message": "Internal server error"
}

Podobnie sytuacja wygląda w przypadku metod HTTP. Każda metoda ma swoje przeznaczenie i powinna być użyta zgodnie z jej przeznaczeniem. Przykładowo, usuwanie zasobów z serwera z wykorzystaniem metody GET będzie błędem. Do tego celu powstała metoda DELETE.

Standardem, któremu warto się przyjrzeć jest ISO 8601. Opisuje on międzynarodowy format zapisu daty i czasu. Nie zawsze dobrą praktyką jest poleganie na własnych standardach, gdyż może on nie być oczywisty dla odbiorcy. Może on być również niemożliwy do wykorzystania w innym środowisku, np. w takim gdzie wykorzystuje się inny język programowania. W JavaScript datę w formacie ISO 8601 można uzyskać wywołując metodę toISOString():

new Date().toISOString() // '2022-08-25T15:55:56.974Z'

Dodatkową korzyścią standardu ISO 8601 jest możliwość obsługi daty przez użytkownika z dowolnej strefy czasowej.

Resource-oriented design

Jednym ze stylów projektowania jaki możesz wykorzystać przy projektowaniu REST API jest Resource-oriented design. Został on zdefiniowany i opracowany przez Google. API budowane zgodnie z Resource-oriented design jest, jak sama nazwa wskazuje, zorientowanie na zasoby. Zasobem w API może być dowolna reprezentacja jakiegoś obiektu, np. użytkownik czy koszyk sklepowy. Dla zasobów modelowane są również relacje między nimi oraz hierarchia zasobów w systemie.

Przykładowo, użytkownik Jan Kowalski będzie należał do kolekcji customers. Każdy customer może mieć n koszyków. Zachodzi więc relacja między koszykami a użytkownikami.

Nazwy zasobów powinny spełniać następujące wymagania:

  • Nazwa zasobu musi być unikalna i wyrażona w liczbie mnogiej, np. customers, carts.
  • W ścieżce do zasobu, chcąc odwołać się do zasobu, jego identyfikator powinien zostać podany po nazwie kolekcji i znaku /, np. customers/123.
  • Każdy zasób musi być jednoznacznie identyfikowalny.
  • Rekomendowane jest identyfikowanie zasobów po nazwie lub po innym naturalnym identyfikatorze. W praktyce jednak nie zawsze jest to możliwe.
  • Nazwy kolekcji powinny być wyrażone w języku angielskim w sposobie zapisu camelCase.
  • Ścieżka do zasobu powinna uwzględniać relacje między zasobami. W przykładzie z użytkownikami i koszykami adres koszyka o id 456 przypisanego do klienta z id 123 będzie wyglądał następująco: customers/123/carts/456. Taki adres pokazuje hierarchię zasobów w systemie – customer jest rodzicem dla cart.

Oprócz zasobów, w API należy zdefiniować również typ zasobu. Typ zasobu można odnieść do analogii klas i obiektów w programowaniu obiektowym. Zasobem będzie obiekt, zaś typ zasobu będzie klasą. Przykładowo, zasoby typu Customer będą dostępne pod ścieżką /customers. Typ zasobu powinien być wyrażony w liczbie pojedynczej i zapisany PascalCasem.

Bazując na powyższych zasadach, można zaprojektować proste API dla klientów i koszyków:

  • GET:/customers – Pobierz dane wszystkich klientów.
  • GET:/customers/{customerId} – Pobierz dane klienta.
  • POST:/customers – Dodaj klienta.
  • DELETE:/customers/{customerId} – Usuń klienta.
  • GET:/customers/{customerId}/carts – Pobierz wszystkie koszyki klienta.
  • GET:/customers/{customerId}/carts/{cartId} – Pobierz konkretny koszyk klienta.

Oczywiście wytyczne Google’a nie są jedynymi słusznymi. Możesz skorzystać z innych wytycznych, np. od Mircrosoftu. Możesz także opracować własne wytyczne. Najważniejsze abyś konsekwentnie ich przestrzegać oraz zaprojektowane w oparciu o nie API będzie przyjazne użytkownikom.

Wersjonowanie

Jednym z problemów publicznie dostępnych interfejsów jest wrażliwość na zmianę. Zmiana ścieżki do zasobów lub zwracanej struktury danych może popsuć aplikacje korzystające z tego API. W celu rozwiązania tego problemu można wykorzystać mechanizm wersjonowania. Dzięki temu, w przypadku zmian psujących (breaking changes) można opublikować nową wersję API. Przykładowo, jeśli w systemie zasób cart został przemianowany na basket, to przeprojektowanie API może udostępniać ścieżki w następującym formacie:

  • GET:/v1/customers/{customerId}/carts/{cartId}
  • GET:/v2/customers/{customerId}/baskets/{basketId}

Należy jednak pamiętać, że wydanie nowa wersja API powinna być pełnoprawnym API. Udostępnienie pojedynczego endpointu ze zmienioną ścieżką będzie frustrujące dla odbiorców. Dodając nową wersję API powinna istnieć możliwość całkowitej migracji na nową wersję.

Oznacza to, że nową wersję API warto wprowadzać rozważnie. Jeśli API cierpi na szereg bolączek, to warto się im przyjrzeć i poprawić „hurtem” wszystkie miejsca, gdzie wprowadzenie poprawki wprowadziłoby zmianę psującą.

W przypadku bardziej subtelnych zmian, np. zmiany nazwy właściwości można oficjalnie zmienić nazwę oczekiwanej właściwości. Z poziomu kodu można zaś oczekiwać wartości pod starą lub nową nazwą.

Paginacja zasobów

Paginacja jest w mojej opinii elementem obowiązkowym w każdym API zwracającym listy zasobów. Mechanizm paginacji pozwala na podzielenie dużej ilości zwracanych wyników w partiach (stronach). Jest to podyktowanie głównie aspektami wydajnościowymi. Załóżmy, że endpoint GET:/customers/{customerId}/carts/{cartId}/items zwraca 100 tysięcy wyników. W takim przypadku wysłanie całości jednorazowo sprawi, że zapytanie zwróci listę 100 tysięcy rezultatów. Jednocześnie trzymanie w pamięci 100 tysięcy obiektów w pamięci po stronie klienta albo wyświetlenie ich wszystkich mija się z celem. Nie będzie to ani czytelne ani wydajne. Nie zawsze konieczne jest też pobranie wszystkich zasobów, często wystarczy kilka stron. Dobry mechanizm paginacji jest konfigurowalny poprzez parametry. Pozwalają one dopasować wyniki do potrzeb użytkownika:

  • Limit zapytań na stronę. Warto zdefiniować maksymalną wartość, aby uniknąć zwracania dużej ilości danych jednym zapytaniem.
  • Przesunięcie (offset) – przydaje się, gdy użytkownik chce pominąć pierwsze n zasobów.
  • Parametr sortowania – pozwala zdefiniować parametr względem którego będą układane strony.
  • Kierunek sortowania – rosnący lub malejący.

Przemieszczanie się pomiędzy stronami może zostać zaimplementowane na kilka sposobów:

  • Poprzez offset – mechanizm dość prosty w implementacji lecz jego wydajność spada wraz z rosnącą ilością danych.
  • Poprzez kursor – z danymi zwracany jest kursor, czyli parametr wskazujący na następną stronę z zasobami. Minusem tego rozwiązania jest nieco trudniejsza implementacja jednak wynagradza nam to wydajność.

Bezpieczeństwo

Jeżeli jakieś zasoby nie powinny być publicznie dostępne to dostęp do nich należy ograniczyć. Metod uwierzytelniania w API jest kilka a ich szczegółowe omówienie to dobry temat na osobny artykuł. W tym wpisie jedynie zaznaczę, że tematowi bezpieczeństwa warto poświęcić dłuższą chwilę i dobrze go przemyśleć.

Radą, którą mogę podzielić się już teraz, to domyślne wymaganie uwierzytelniania dla każdej ścieżki. Publicznie dostępne ścieżki w API powinne być traktowane jako wyjątek. Pamiętaj, że koszty udostępnienia zasobu z ograniczonym dostępem są znacznie wyższe niż koszty przypadkowego zablokowania dostępu do publicznego zasobu.

Ponadto, pamiętaj aby zabezpieczyć API przed przesyłaniem w zapytaniach złośliwych treści. Mam tu na myśli głównie payloady dla ataków np. SQL Injection czy XSS.

Dostępność API

Najlepiej zaprojektowane API nie przynosi wartości jeśli nie działa. Do sprawdzania czy API, mówiąc kolokwialnie, żyje można wykorzystać health endpoint. Taki endpoint zwykle jest dostępny pod ścieżką /health. Do odpytania takiego endpointu zwykle wykorzystuje się metodę GET lub HEAD. Odpytanie /health nie musi wiązać się jedynie ze zwykłym pingiem serwera. Zapytanie do /health może wywołać w systemie sprawdzenie dostępności jego komponentów np. baz danych i zwrócić statusy dla każdego z nich.

Dodając taki endpoint do API koniecznie należy pamiętać o wyłączeniu dla niego cache’owania.

Oprócz monitorowania zdrowia API warto jest zadbać o monitoring ruchu. Monitorując zapytania wykonywane do API będziesz w stanie wykryć ile zapytań jest wykonywane do poszczególnego endpointu. Pozwoli Ci to wykryć kluczowe oraz nieużywane endpointy. Ponadto, monitoring pozwoli Ci na wykrycie błędów rzucanych przez API oraz pokaże skalę problemu. Błędy rzucane przez API warto jest zapisywać w logach aby ułatwić sobie proces debugowania.

Do monitoringu możesz skorzystać na przykład z Grafany. Do podstawowego monitoringu powinna Ci wystarczyć wersja Cloud w darmowym wariancie, dzięki czemu małym nakładem pracy będziesz w stanie przekonać się o sile tego narzędzia.

Dokumentacja

Dokumentacja to ostatni, ale nie mniej ważny niż pozostałe, element projektowania API. W temacie dokumentacji REST API prym wiedzie specyfikacja OpenAPI. Jest to standard który zarówno ludziom jak i komputerom pozwala zrozumieć strukturę zaprojektowanego API bez dostępu do kodu źródłowego. Definicje API zgodne z OpenAPI pozwalają zautomatyzować proces generowania dokumentacji oraz umożliwiają integrację z innymi systemami opartymi o ten standard.

Definicje OpenAPI zdefiniowane są w formacie YAML lub JSON. Z OpenAPI nierozerwalnie związany jest Swagger, który umożliwia na zbudowanie dokumentacji w oparciu o definicje OpenAPI. Jego możliwości możesz poznać korzystając z aplikacji demo. Jeśli szata graficzna Swaggera Ci nie odpowiada, to możesz skorzystać z Redoca. Jest to, moim zdaniem, znacznie bardziej przyjemna dla oka alternatywa.

Podsumowanie

Zostawiam Cię w tym momencie ze sporą ilością nowej wiedzy oraz ogromem materiałów dodatkowych, do których przeczytania serdecznie zachęcam. Zapraszam do zostawienia komentarza pod artykułem oraz sprawdzenia linków pod artykułem. Udanego projektowania!

Źródła i materiały dodatkowe:

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.