A REST API hypertext-driven legyen
Bár erős vitákat tapasztalok a REST körül, valahogy a hypertext-driven API-val ritkán találkozom.
Roy T. Fielding, a REST megálmodója a gyakran idézett REST APIs must be hypertext-driven cikkjében a következőt mondja:
In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period.
Erre erősít rá a Richardson Maturity Model is, mely három lépésben mutatja be a REST alapvető elemeit:
- Level 1 - Resources
- Level 2 - HTTP Verbs
- Level 3 - Hypermedia Controls
És egy API csak akkor nevezhető RESTfulnak, ha az összes szinten leírtakat teljesíti, igen a hypermedia control használatát is.
De mit is jelent ez? Az állítás egyszerű: a kliensnek minden előzetes tudás nélkül igénybe kell tudnia venni a RESTful API mögötti szolgáltatásokat, és ez linkek segítségével valósulhat meg.
Pl. az előző Workflow REST API-n posztomban szereplő Issue Tracker alkalmazás a következőképp adja vissza a hibajegyek listáját:
{
"_embedded": {
"jtl:issueResourceList": [
{
"id": 1,
"title": "Write a post about REST",
"state": "NEW",
"_links": {
"self": {
"href": "http://localhost:8080/api/issues/1"
},
"actions": {
"href": "http://localhost:8080/api/issues/1/actions"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/api/issues"
}
}
}
Látható a hibajegyek listáját reprezentáló URL-jét tartalmazó self
link, valamint hibajegyenként
a hibajegyet reprezentáló URL-jének linkje szintén self
névvel, valamint a hibajegyhez tartozó
lépéseket reprenzentáló URL actions
névvel.
Ez a Hypermedia as the engine of application state (HATEOAS), melyet szintén Roy T. Fielding vezetett be a disszertációjában. A HATEOAS szerint a szerver a kliens számára linkekkel jelzi, hogy mit tehet.
Az elv mögötte az, hogy a REST API-t is úgy lehessen használni, mint a webet. Ahol vannak oldalak és linkek és semmilyen előzetes információra nincs szükségem a használatához.
Hogy hogyan viszonyul ez az OpenAPI-hoz (Swaggerhez)? Elvileg a cél ugyanaz, és míg a HATEOAS a REST gonolatiságához illeszkedik, az OpenAPI inkább az RPC-hez.
Implementáció
Ennek előállítására létezik egy Spring HATEOAS projekt.
A teljes forráskód megtalálható GitHubon.
A legegyszerűbb megoldás, hogy legyenek a resource osztályaim a RepresentationModel
osztály leszármazottjai.
public class IssueResource extends RepresentationModel<IssueResource> {
}
Innentől kezdve meg tudom mondani, hogy milyen linkei legyenek:
model.add(linkTo(methodOn(IssueController.class).getIssue(model.getId())).withSelfRel());
model.add(linkTo(methodOn(IssueController.class).getIssueActions(model.getId())).withRel("actions"));
Az is látható, hogy nem kézzel állítom össze az URL-t (bár megtehetném), hanem a controller osztály metódusain található annotációkra támaszkodom. Így elkerülöm az ismétlést, és ha változtatok az URL-en, elég csak egy helyen ezt megtenni.
Amennyiben kollekcióm van, a controllerből nem List
típussal, hanem
CollectionModel
típussal térek vissza.
public class IssueController {
@GetMapping
public CollectionModel<IssueResource> getIssues() {
var issues = issueService.getIssues();
return CollectionModel.of(issues).withFallbackType(IssueResource.class);
}
}
A CollectionModel
az of()
metódussal gyártható le. Mivel üres lista esetén elveszik
a generikus típusaban tárolt típusinformáció, ezért hívom meg a withFallbackType()
metódust.
Többször előfordul, hogy nem én példányosítom le a resource-ot, hanem készen kapom. Pl. mikor a perzisztens rétegből jön vissza:
public interface IssueRepository extends JpaRepository<Issue, Long> {
@Query("select new issuetracker.IssueResource(i.id, i.title, i.state) from Issue i where i.id = :id")
Optional<IssueResource> findIssueResourceById(long id);
@Query("select new issuetracker.IssueResource(i.id, i.title, i.state) from Issue i order by i.title")
List<IssueResource> findAllIssueResourceOrderByTitle();
}
Ilyenkor használható a RepresentationModelProcessor
. Ez ugyanis észreveszi, ha
egy controllerből RepresentationModel
-lel térek vissza, és automatikusan kiegészíti a
linkekkel.
@Component
public class IssueResourceProcessor implements RepresentationModelProcessor<IssueResource> {
@Override
public IssueResource process(IssueResource model) {
model.add(linkTo(methodOn(IssueController.class).getIssue(model.getId())).withSelfRel());
model.add(linkTo(methodOn(IssueController.class).getIssueActions(model.getId())).withRel("actions"));
return model;
}
}
És ekkor már a megfelelő JSON-nel tér vissza a REST hívás. A válasz content type-ja application/hal+json
.
A Spring HATEOAS ugyanis több media type-ot is támogat. Ezek különböző reprezentációk. Ebből az első
és alapértelmezetten támogatott a Hypertext Application Language (HAL). Ez azt írja le, hogy a
linkeknek hogyan kell megjelenniük a JSON-ben.
Ebben van egy az XML névtérnek megfelelő fogalom, amivel minősíteni tudjuk a saját linkjeinket. Ez a Curie. Ezt a következő beannel kapcsolhatjuk be.
@Bean
public CurieProvider curieProvider() {
return new DefaultCurieProvider("jtl", UriTemplate.of("https://jtechlog.hu/rels/{rel}"));
}
Ekkor kicsit változik a JSON.
{
"_embedded": {
"jtl:issueResourceList": [
{
"id": 1,
"title": "Write a post about REST",
"state": "NEW",
"_links": {
"self": {
"href": "http://localhost:8080/api/issues/1"
},
"jtl:actions": {
"href": "http://localhost:8080/api/issues/1/actions"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/api/issues"
},
"curies": [
{
"href": "https://jtechlog.hu/rels/{rel}",
"name": "jtl",
"templated": true
}
]
}
}
Látható, hogy megjelent egy jtl
nevezetű Curie, melynek definíciója
https://jtechlog.hu/rels/{rel}
. Majd a linkek nevei ezzel
vannak minősítve: jtl:actions
. Igazából az említett címre kéne
elhelyezni a dokumentációt.
A HAL nem támogatja, hogy megmondjuk, hogy milyen URL-eken milyen
HTTP metódusok támogatottak. Ehhez akár egy OPTIONS
kérést
tudunk beküldeni.
> OPTIONS http://localhost:8080/api/issues
< HTTP/1.1 200
< Allow: GET,HEAD,POST,OPTIONS
< Accept-Patch:
< Content-Length: 0
< Date: Wed, 11 Oct 2023 09:42:28 GMT
< Connection: close
Azaz a /api/issues
címen a GET
, HEAD
, POST
és OPTIONS
támogatott.
Van olyan media type, ami ezt is leírja, ez a HAL-FORMS. A Spring HATEOAS referencia dokumentációjában megtalálható még több media type is.
Persze ezt kliens oldalon is támogatni kell, azaz nem beégetni az URL-eket, hanem dinamikusan lekérdezni.
Erre hasznos pl. a Traverson JavaScript
library, vagy a Spring HATEOAS projekt Traverson
osztálya.
Az, hogy ez kinek mennyire hasznos, mindenki döntse el saját maga. De ne feledd, Roy T. Fielding szerint enélkül nem REST az API-d.