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.