Wersjonowanie REST API dzięki Content-type

pater.dev 4 lat temu

Nasze API to kontrakt, który zawieramy z naszymi klientami na zasadzie: „Tak będziemy od dzisiaj ze sobą rozmawiać”. Niestety w praktyce zwykle przychodzi ten moment kiedy sposób naszej rozmowy musi się delikatnie (lub diametralnie) zmienić.

Powody mogą być różne – uznaliśmy, iż może warto w końcu pozbyć się XML’a na rzecz JSON’a, albo iż jednak aktualna struktura zwracanego dokumentu nie jest już wystarczająca i chcielibyśmy jeszcze tam dorzucić kilka dodatkowych informacji, albo biznes… No powodów może sporo.

Oczywiście o ile mamy prostą aplikację serwer – klient i jedynym klientem jest front utrzymywany przez nasz zespół to wprowadzanie takich wersji jest prawdopodobnie całkowicie niepotrzebne, ponieważ przepięcie się na nową wersję API odbędzie się prawdopodobnie jeszcze przed wypuszczeniem nowej wersji aplikacji (gdzie front zdążył się już dostosować).

Zatem kiedy może zaistnieć potrzeba wersjonowania naszego API?

Przede wszystkim wtedy gdy nie jesteśmy w stanie zaplanować, iż klienci naszego API będą w pełni zintegrowani z nowszą wersją w tym samym momencie gdy nowsza wersja zostanie wydana.

Zmieniamy coś w naszym kontrakcie, dostosowujemy nasz front pod nowy kontrakt. Wszystko super, feature oddany, story pointy spalone. Wrzucamy na produkcję, udało się i zapominamy o problemie. Do czasu, aż nie przychodzi ktoś z całkowicie innego zespołu z pytaniem czy zostało zmienione coś w API, bo aplikacja mobilna zaczęła coś świrować. Okazało się, iż zespół aplikacji mobilnych nie potrzebował zmian, które zostały wprowadzone w nowszej wersji więc przepięcie się na nową wersję przełożył na kolejny sprint.

Dajmy czas naszym klientom na dostosowanie się do nowej wersji (o ile jest to możliwe).

Krótkie przypomnienie REST

W przypadku REST adres URI określa zasób

/posts/123

Jest to URI do zasobu jakim jest post o ID 123.

Sama reprezentacja zasobu powinna zostać określona dzięki nagłówków HTTP.

Content-Type: application/json Accept: application/json

Nie łączymy zasobu z reprezentacją:

/posts/123.json

Analogicznie przecież jest z metodami typu GET, DELETE

Załóżmy, iż chcemy usunąć post o ID 123. Nie robimy w tym celu URI:

/posts/123/delete

Tak samo jak podczas odczytywania informacji o poście 123 nie uderzamy pod URI:

/posts/123/info

Uderzamy zwykle pod jeden URI zasobu na którym chcemy wykonać daną operację.

DELETE /posts/123 GET /posts/123

Od czegoś w końcu te metody są. Gdyby nie one to nasze API byłoby strasznie nieintuicyjne. W przypadku usunięcia zasobu jedna osoba zrobiła by URI z końcówką delete, inna remove, a w intercity pewnie byłoby to usun.

Wersjonowanie dzięki Content-type

Załóżmy, iż mamy 2 wersje naszego API. Musimy jednak zachować pełną kompatybilność wsteczną. Niech nasz endpoint wygląda aktualnie tak:

@RestController public class Controller { //autowired PostService @GetMapping(value = "posts/{id}") public String post(@PathVariable int id) { return postService.getOne(id); } }

Potrzebujemy teraz dodać nową wersję naszego endpointu.

@RestController public class Controller { private final PostService postService; @Autowired public Controller(PostService postService) { this.postService = postService; } @GetMapping(value = "posts/{id}", produces = "application/json;version=1") public String post1(@PathVariable int id) { return postService.getOne(id, "1.0"); } @GetMapping(value = "posts/{id}", produces = "application/json;version=2") public String post2(@PathVariable int id) { return postService.getOne(id, "2.0"); } }

Jak widzimy – mamy ten sam adres, ale wybór metody, która obsłuży zapytanie, podyktowane jest nagłówkiem żądania.

Klient wykonujący operację:

GET http://localhost:8080/posts/15 Accept: application/json;version=1

Dostanie odpowiedź:

HTTP/1.1 200 Content-Type: application/json; version=1;charset=UTF-8 Version 1.0 of 15

Natomiast inny, który przepiął się na nowszą wersję:

GGET http://localhost:8080/posts/15 Accept: application/json;version=2

Zobaczy:

HTTP/1.1 200 Content-Type: application/json; version=2;charset=UTF-8 Version 2.0 of 15

Czyli wszystko gra super. Uderzamy pod ten sam zasób, ale dostajemy jego inną reprezentację.

Z którego endpointu będą korzystać klienci bez podanej wersji?

Wprowadziliśmy wersjonowanie, podaliśmy z jakiej wersji chcemy skorzystać i rzeczywiście działa. Co jednak z tą naszą kompatybilnością wsteczną w przypadku klientów, którzy nie podali jawnie wersji z jakiej chcą skorzystać?

Dokładniej – która metoda zostanie obsłużona przez zapytanie:

GET http://localhost:8080/posts/15

Jeżeli chodzi o Springa odpowiada, tak jak tego oczekujemy, wersją wcześniejsza:

HTTP/1.1 200 Content-Type: application/json; version=1;charset=UTF-8 Version 1.0 of 15

Pomijając przeczesywanie dokumentacji – wystarczy szybki debug w jaki sposób zapytania HTTP są mapowane na metody.

Spring (webmvc-5.2.2) co prawda widzi 2 pasujące metody na które może zmappować nasze zapytanie:

Ale następnie sortuje je dzięki porównania (przyznacie, iż te zagnieżdżenia dosyć paskudnie wyglądają):

I porównanie kończy się właśnie na wybraniu leksykograficznie pierwszej (bo wszystkie poprzednie warunki są identyczne) metody na podstawie adekwatności produces.

Oznacza to tyle, iż o ile zmienimy teraz wersję „2” na wersję „0”:

@GetMapping(value = "posts/{id}", produces = "application/json;version=0") public String post2(@PathVariable int id) { return postService.getOne(id, "2.0"); }

To nasze zapytanie:

GET http://localhost:8080/posts/15

Tym razem odpowie:

HTTP/1.1 200 Content-Type: application/json; version=1;charset=UTF-8 Version 2.0 of 15

Proste, łatwe, przyjemne. Bez wymyślania jakichś dziwnych obejść, dodawania kolejnej cyferki do adresu URI. Zasób dalej jest pod tym samym miejscem.

Podsumowując – korzystajmy z nagłówków HTTP, od tego one są!

Idź do oryginalnego materiału