Blättern mit Spring HATEOAS

In den Beiträgen REST in Peace und REST heißt HATEOAS wurde schon auf die Möglichkeiten des HATEOAS Patterns hingewiesen. Die Verlinkung von Resourcen nutzen um innerhalb der Client Anwendung das notwendige Wissen um die Server API zu reduzieren, entkoppelt Systeme und vereinfacht die Entwicklung und Wartung.

Auch in diesem Beitrag wird es um die Implementierung im Stammbaum Projekt gehen. Das Projekt stellt einen Endpoint zur Verfügung um eine Person aus der Ahnendatenbank anhand einer ID abzufragen.

@GetMapping("/ancestors/{id}")
public EntityModel<PersonDto> findById(@PathVariable @Min(1) Long id) {
  return repository.findById(id, PersonDto.class).map(this::createEntityModel).orElseThrow();
}

private EntityModel<PersonDto> createEntityModel(PersonDto person) {
  return new EntityModel<>(person,
    linkTo(methodOn(AncestorController.class).findById(person.getId())).withSelfRel(),
    linkToFamily(person),
    linkTo(methodOn(AncestorController.class).findAll()).withRel("all"));
}

Der Endpoint liefert keine Instanz vom Typ PersonDto zurück sondern von EntityModel<PersonDto>. Die Klasse EntityModel sorgt dafür, dass die beiden Links mit den Relationen „self“, und „all“ für die Person in der Response erscheint. Zusätzlich werden je nach Familienstand noch die Links mit den Relationen „father“, „mother“, „wife“, „husband“, „family“ durch die Methode linkToFamily eingefügt.

{
  "sex": "♂",
  "name": "Johann Christoph /Kaiser/",
  "birth": { "place": "Bremen, Deutschland" },
  "urn": "urn:gina:gebdas-1208699983"
  "_links": {
    "self": {"href":"XXX/ancestors/1"},
    "all": {"href":"XXX/ancestors"},
    "wife": { "href": "XXX/ancestors/2" },
    "family": { "href": "XXX/families/1" },
    "father": { "href": "XXX/ancestors/3" },
    "mother": { "href": "XXX/ancestors/4" },
  }
}

Möchte eine Anwendung nun etwas über die Mutter von Johann Christoph erfahren, dann muss nur der Link mit der Relation „mother“ aufgerufen werden. Details zur Hochzeit und den Kinder erhält die Anwendung über den Link mit der Relation „familiy“.

Um alle Personen in der Ahnendatenbank zu erhalten, kann die Anwendung den Link mit der Relation „all“ verwenden und damit die folgende Methoden nutzen.

@GetMapping("/ancestors")
public CollectionModel<EntityModel<PersonDto>> findAll() {
  List<EntityModel<PersonDto>> ancestors = repository.findBy(PersonDto.class).stream()
    .map(this::createShortEntityModel).collect(toList());
  return new CollectionModel<>(ancestors,
       linkTo(methodOn(AncestorController.class).findAll()).withSelfRel());
}

Statt einer Liste von Personen liefert diese Methode ein CollectionModel. Ein CollectionModel sorgt ähnlich wie das EntityModel dafür, dass eine Liste als Resource mit Links versehen werden kann. Im Gegensatz zur ersten Methode wird hier nicht createEntityModel aufgerufen sondern createShortEntityModel. Der Unterschied zwischen den beiden Methoden ist, dass in dieser Response nur der „self“ Link einer Person angegeben werden soll. Das Ergebnis einer Anfrage nach allen Ahnen sieht dann wie folgt aus.

{
  "_embedded": {
    "personList":[
      { "name":"Johann Christoph /Kaiser/",
        "_links": {
          "self": {"href":"XXXX/ancestors/1"}
        }
      },
      .... 199 weitere Einträge
    ]
  },
  "_links": {
    "self": { "href":"XXX/ancestors" }
  }
}

Die eigentliche Nutzlast der Antwort, die Liste aller Personen, steht unter „_embedded.personList„.

Das ist soweit sehr schön, aber wie sieht es aus bei Hunderten von Personen im System? Dann werden meist nicht alle Personen benötigt sondern nur ein Ausschnitt.

Erfreulicherweise unterstützt Spring Boot HATEOS auch diese Funktionalität, indem es auf Features von Spring Data JPA zurückgreift. Um nur einen Auschnitt eines Query Resultates zu verwenden, wird dort ein Pageable verwendet. Instanzen dieses Types geben an, welche Page mit welcher Größe auf einem Resultat zurückgegeben werden soll.

@GetMapping("/ancestors")
public CollectionModel<EntityModel<PersonDto>> findAll(Pageable pageable, PagedResourcesAssembler<PersonDto> assembler) {
  Page<PersonDto> ancestorPage = repository.findBy(pageable, PersonDto.class); 
  return assembler.toModel(ancestorPage, this::createShortEntityModel);
}

Der Controller ruft auf dem Repository statt der Methode findBy(PersonDto.class) die Methode findBy(pageable, PersonDto.class) auf. Diese Methode ist im Interfaces PersonRepository definiert. Wer lieber auf auf Entitäten arbeitet, kann die Methode findAll(pageable) im Spring Data JPA Interface PagingAndSortingRepository verwenden. Die Methode liefert keine Liste zurück, sondern eine Page Instanz. Diese enthält maximal so viele Einträge wie das Pageable angefordert hat. Außerdem enthält die Page Instanz die Gesamtzahl der Seiten und Elemente der vollständigen Suche.

Wem die dazugehörige zweifache Suche zu teuer ist, der kann auf den Typ Slice wechseln. Der Slice weiß nur, ob noch mehr Elemente existieren. Dafür wird ein Element mehr geordet als vom Pageable gefordert.

Ein erstes Feature von Spring HATEOS das wir hier verwenden, ist die Unterstützung von Pageable Parametern in Endpoint Methoden. Der Framework füllt die Instanz automatisch mit passenden Query-Parametern „page“ und „size“ aus dem Request. Genaugenommen gibt es noch einige weitere Parameter zur Sortierung, aber in diesem Beitrag geht es um das Blättern.

Der zweite Parameter assembler des Endpoints ist ein Hilfsmittel von Spring HATEOAS um PageModel Instanzen zu erstellen. Die Methode toModel erstellt aus der übergebenen Page Instanz ein PageModel. Mit Hilfe des zusätzlich übergebenen RepresentationModelAssembler wird für jedes Element aus der Page Instanz ein EntityModel in das PageModel eingefügt. Der RepresentationModelAssembler muss nicht extra implementiert werden. Hier reicht die Methodenreferenz this::createShortEntityModel.

Das Ergebnis einer Anfrage nach allen Ahnen sieht dann wie folgt aus.

{
  "_embedded": {
    "personList":[
      { "name":"Johann Christoph /Kaiser/",
        "_links": {
          "self": {"href":"XXXX/ancestors/1"}
        }
      },
      .... 19 weitere Einträge
    ]
  },
  "_links":{
    "first": {"href":"XXX/ancestors?page=0&size=20"},
    "self":  {"href":"XXX/ancestors?page=0&size=20"},
    "next":  {"href":"XXX/ancestors?page=1&size=20"},
    "last":  {"href":"XXX/ancestors?page=2&size=20"}
  },
  "page": {
    "size": 20,
    "totalElements": 200,
    "totalPages": 10,
    "number": 0 }
  }
}

Der Unterschied zur vorherigen Variante sticht sofort ins Auge. Neben dem neuen page Bereich ist auch die Zahl der Links angewachsen. In dieser Darstellung nicht zu erkennen ist die Reduzierung der Liste von 200 auf 20 Einträge.

Der neue page Bereich zeigt die Größe der Seite, die Gesamtzahl aller Seiten und Elemente und die Nummer der aktuellen Seite an. Da beim Aufruf keine Query-Parameter gesetzt wurden, verwendet der Framework standardmäßig 20 Elemente pro Seite und den Index 0 der ersten Seite.

In Bereich für die Links finden sich neben dem „self“ Link noch drei weitere Links. Das ist „next“ für die nächste Seite, „first“ für die erste Seite und „last“ für die letzte Seite. Da dies die erste Seite ist, fehlt der Link „previous“ für die vorhergehende Seite.

Auch hier zeigt sich die Eleganz des HATEOAS Ansatzes. Eine Anwendung muss keinerlei Berechnung ausführen um einen Aufruf für die letzte Seite zu erhalten. Es reicht zu wissen, dass ein entsprechender Link hinter der Relation „last“ zur Verfügung gestellt wird.