REST API with Spring Boot 3 — Part 2
Pagination, HATEOAS, Root Entry Point and Pagination
This is the second post of my REST API series, where I write about my impressions and experience with this architecture style using Java with Spring Boot.
The full series of articles are listed below:
- Part I: Project setup and API implementation with Spring Boot
- Part II: HATEOAS, Root Entry Point and Pagination
- Part III: API documentation using Swagger
- Part IV: API Authentication and Authorization with Spring Security
The implementation of each article is available on GitHub in a specific branch, and the final implementation is on the main branch.
In this article we will go through HATEOAS and Root Entry Point definitions and implementation.
The code of this article is available on GitHub in branch ‘ch-02’.
HATEOAS
Hypermedia as the Engine of Application State (HATEOAS) is “a constraint of the REST application architecture that keeps the RESTful style architecture unique from most other network application architectures. The term “hypermedia” refers to any content that contains links to other forms of media (images, movies, text…)” [1].
HATEOAS are elements embedded in the response messages of resources which drives the interaction for the API client. So it is possible to the client to dynamically navigate and discover resources by traversing the hypermedia links. Each resource can provides links to some more (detailed) information.
It’s very close to the concept of browsing web pages by accessing hypermedia links to navigating through the content.
HATEOAS complies to Level 3 of Richardson Maturity Model of Rest APIs.
For example, imagine a /movie endpoint (URI) where it was created a new movie resource identified by the id = 10 (creation by POST request). The response can have links to represent current state and further operations, like this [2]:
// json response representation
{
"movie": {
"id": 10,
"name": "Back to the Future",
"releaseYear": 1985
"links": [
link: {
"rel": "self",
"uri": "/movie/10"
},
link: {
"rel": "comments",
"uri": "/movie/10/comments"
}
]
}
}
Implementation
Using the API built in the first article, we will use “Spring HATEOAS” to add this feature.
Spring HATEOAS helps creating REST representations that follow HATEOAS principles when working with Spring. The core problem it tries to address is link creation and representation assembly.
Let’s start by adding the new dependency in pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
Next, we need to make the Response DTOs extends the ‘RepresentationModel’ class, to provide ‘links’ attribute.
public class BookResponse extends RepresentationModel<BookResponse>
public class ReviewResponse extends RepresentationModel<ReviewResponse>
Now we are able to update the Controller methods to add the links in the responses using the ‘WebMvcLinkBuilder’ class (which was made static import):
// altered methods:
@GetMapping
public ResponseEntity<List<BookResponse>> getBooks() {
var books = service.findBooks();
var booksResp = books.stream().map(bookMapper::toResponse)
.map(b -> b.add( linkTo(methodOn(this.getClass()).getBook(b.getId())).withSelfRel() ))
.toList();
return ResponseEntity.ok(booksResp);
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable("id") Integer id) {
var book = service.findBookById(id);
var bookResp = bookMapper.toResponse(book)
.add( linkTo(methodOn(this.getClass()).getReviews(id)).withRel("reviews") )
.add( linkTo(methodOn(this.getClass()).getBooks(null, null, null, null))
.withRel("books") );
return ResponseEntity.ok(bookResp);
}
@GetMapping("{bookId}/reviews")
public ResponseEntity<List<ReviewResponse>> getReviews(@PathVariable("bookId") Integer bookId) {
var reviews = service.findReviews(bookId);
var reviewsResp = reviews.stream()
.map(ReviewResponse::new)
.map(r -> r.add( linkTo(methodOn(this.getClass()).getBook(bookId)).withRel("book") ))
.toList();
return ResponseEntity.ok(reviewsResp);
}
Notice that each resource link point to a controller method, so if the URI (path) changes the responses automatically uses the new path, and the server part of the URI is loaded according to each environment.
Now the same GET requests return the resources links:
Root Entry Point
An API may have many resources and endpoints. It’s a good practice to an REST API to have exactly one entry point, that serves as a catalog of the resources available in the API. The URL of the entry point needs to be communicated to the clients so that they can find the API. This root entry point will ‘teach’ the client what endpoints it need to reach in order to make proper requests.
A good analogy is when you access the root URL of a shopping web page, and then see the products available and start navigating from their links.
Following the previous definition, an example of a root entry point could be: http://api.company-name.com or http://company-name.com/api/ .
So, that is a simple definition, and there is no need to add any dependency to achieve it.
We can create a new Controller to serve as a ‘RootEntryPointController’ which response will contain just a list of resources available (in this case, just ‘books’ resource).
@RestController
public class RootEntryPointController {
@GetMapping
public ResponseEntity<RootEntryPointResponse> getRoot() {
RootEntryPointResponse resp = new RootEntryPointResponse()
.add( linkTo(methodOn(BooksController.class).getBooks(null, null, null, null))
.withRel("books") );
return ResponseEntity.ok(resp);
}
}
The response to that root endpoint will be like this:
Pagination
Sometimes, we have a large dataset, and when we fetch data from an endpoint that returns large amount of data it can make things complicated.
So, it’s better to return the data in chunks, that we name ‘pages’, which are sorted based on some criteria.
In the API built in the first article, we are using Spring Data, which provides a simple way to achieve pagination to our responses.
We can use, for example, the find all books endpoint, where we can place some request parameters to define the size of the page, the number of requested page, some field to be used to sort the result and the direction of sorting (ASC, or DESC). These parameters are optional, and can assume default values if not informed by the client.
Implementation
First of all, let’s add the parameters in the controller class:
@GetMapping
public ResponseEntity<List<BookResponse>> getBooks(
@RequestParam(value = "size", required = false, defaultValue = "3") Integer size,
@RequestParam(value = "page", required = false, defaultValue = "0") Integer pageNumber,
@RequestParam(value = "sort", required = false, defaultValue = "title") String sortField,
@RequestParam(value = "direction", required = false, defaultValue = "ASC") String sortDirection) {
var books = service.findBooks(size, pageNumber, sortField, sortDirection);
var booksResp = books.stream().map(bookMapper::toResponse)
.map(b -> b.add( linkTo(methodOn(this.getClass()).getBook(b.getId())).withSelfRel() ))
.toList();
return ResponseEntity.ok(booksResp);
}
And now we have to make the service class to pass these parameters through, and the magic happens in the BookGatewayRepository, that now makes use of the ‘findAll()’ overloaded version of the method in the Repository that expects a PageRequest as an argument:
public List<Book> findAll(Integer size, Integer pageNumber, String sortField, String sortDirectionStr) {
Sort.Direction sortDirection = Sort.Direction.valueOf(sortDirectionStr);
PageRequest pageable = PageRequest.of(pageNumber, size).withSort(sortDirection, sortField);
Page<BookEntity> page = bookRepo.findAll(pageable);
var entities = page.toList();
return entities.stream().map(mapper::toModel).toList();
}
We can adjust the tests classes with these new methods signatures, and test its behaviour using Postman too: