Spring Boot REST API CRUD with HATEOAS Tutorial
- Details
- Written by Nam Ha Minh
- Last Updated on 05 November 2023   |   Print Email
In this REST API tutorial with Spring Boot, you will learn how to add Hypermedia as the Engine of Application State (HATEOAS) features to your RESTful APIs with CRUD (Create, Retrieve, Update and Delete) operations. This tutorial is the upgrade version of the Spring Boot RESTful CRUD API Examples article.
Technologies will be used:
- Spring Boot: simplifies development of Spring-based projects
- Spring Data JPA: simplifies coding of data access layer, with Hibernate is the default ORM framework
- Spring Web: simplifies developing web-based apps, especially RESTful webservices and REST APIs
- Spring HATEOAS: eases creation of links embedded in JSON
To follow this tutorial, I recommend you the following software programs:
- Java Development Kit: OpenJDK or Oracle JDK, version 11 or 17
- a Java IDE (IntelliJ, Eclipse or Spring Tool Suite)
- Postman or curl for API testing
And also note that the sample project in this tutorial will use H2 in-memory database, for the shake of simplicity, as we focus on HATEOAS-driven REST APIs.
1. What is HATEOAS?
HATEOAS or Hypermedia As The Engine of Application State is a constraint of the REST application architecture that makes web APIs actually “RESTful”. Basically, for a request, the server sends only data to the client. With HATEOAS, the response includes not just data but also possible actions related to that data, in form of links.
Let’s see an example. Below is the response of a GET request to the end point URI /api/accounts/3:
{ "id": 3, "accountNumber": "1982094128", "balance": 6211, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" }, "deposits": { "href": "http://localhost:8080/api/accounts/3/deposit" }, "withdrawls": { "href": "http://localhost:8080/api/accounts/3/withdraw" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
As you can see, in addition to the data (id, accountNumber and balance), the JSON document also includes 4 links that allow the REST client to perform actions related to that data, such as get account details - specified by the link relation (rel) is self, deposit an amount (with rel deposits), withdraw an amount (with rel withdrawals) or get all accounts (with rel collection).
So in this way of communication, from a base URI, the clients can follow the links included in server’s responses in order to navigate resources and perform related actions - or change states of resources - hence the term Hypermedia as the Engine of Application State. The term hypermedia refers to any content that contains links to other form of media such as text, images, movies.
HATEOAS in REST APIs is like hyperlinks in web pages: the users can browse a website from a domain name and click on hyperlinks to explore content without prior knowledge of hyperlinks. And REST clients do not need to have prior knowledge of resource URIs to use APIs - all they need is a base URI from which they can traverse and follow the links included in responses.
And the greatest advantage of HATEOAS is that it decouples server and client. Developers can update and evolve their APIs without worrying about breaking clients because the client should make no assumption about resource URIs rather than the base one.
2. What are Spring HATEOAS and HAL?
Building REST APIs that follow HATEOAS principle is not easy. So Spring HATEOAS provides some APIs that ease the creation of hypermedia links in API responses (links in JSON documents). It works well with Spring MVC and Spring Data JPA and supports for hypermedia formats like HAL (Hypertext Application Language).
HAL is a standard which establishes conventions for expressing hypermedia controls, such as links, with JSON. By following HAL, you build REST APIs that can be consumed by any REST clients that understand HAL.
Note that most of the classes and interfaces relate to HATEOAS can be found in the package org.springframework.hateoas.
3. Setup Spring Boot Project
Let’s create a Java Spring Boot project for a web application that provides some HATEOAS-driven REST APIs for bank account operations.
First, make sure the Maven project file pom.xml includes the following dependencies:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>
Besides spring-boot-starter-web that supports RESTful webservices and spring-boot-starter-jpa that supports JPA repositories, we use spring-boot-starter-hateoas that simplifies the creation of hypermedia links for REST APIs. We also use h2 for in-memory database.
Next, in the repository layer, code an entity class that represents a bank account:
package net.codejava.account; import javax.persistence.*; @Entity @Table(name = "accounts") public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(nullable = false, unique = true, length = 20) private String accountNumber; private float balance; public Account() { } public Account(String accountNumber, float balance) { this.accountNumber = accountNumber; this.balance = balance; } // getters and setters are not shown for brevity... }
And code a POJO class that represents an amount used to deposit and withdraw:
package net.codejava.account; public class Amount { private float amount; public Amount() {} public Amount(float amount) { this.amount = amount; } public float getAmount() { return amount; } public void setAmount(float amount) { this.amount = amount; } }
And code a JPA repository interface as follows:
package net.codejava.account; import javax.transaction.Transactional; import org.springframework.data.jpa.repository.*; import org.springframework.stereotype.Repository; @Repository public interface AccountRepository extends JpaRepository<Account, Integer> { @Query("UPDATE Account a SET a.balance = a.balance + ?1 WHERE a.id = ?2") @Modifying @Transactional public void deposit(float amount, Integer id); @Query("UPDATE Account a SET a.balance = a.balance - ?1 WHERE a.id = ?2") @Modifying @Transactional public void withdraw(float amount, Integer id); }
Next, in the service layer, code the service class as shown below:
package net.codejava.account; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional public class AccountService { private AccountRepository repo; public AccountService(AccountRepository repo) { this.repo = repo; } List<Account> listAll() { return repo.findAll(); } Account get(Integer id) { return repo.findById(id).get(); } Account save(Account account) { return repo.save(account); } Account deposit(float amount, Integer id) { repo.deposit(amount, id); return repo.findById(id).get(); } Account withdraw(float amount, Integer id) { repo.withdraw(amount, id); return repo.findById(id).get(); } void delete(Integer id) { repo.deleteById(id); } }
And for the database, code the following Spring configuration class that initializes some sample data upon applications startup:
package net.codejava; import java.util.List; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class LoadDatabase { private AccountRepository accountRepo; public LoadDatabase(AccountRepository accountRepo) { this.accountRepo = accountRepo; } @Bean public CommandLineRunner initDatabase() { return args -> { Account account1 = new Account("1982080185", 1021.99f); Account account2 = new Account("1982032177", 231.50f); Account account3 = new Account("1982094128", 6211.00f); accountRepo.saveAll(List.of(account1, account2, account3)); System.out.println("Sample database initialized."); }; } }
This will insert 3 rows into the accounts table when the application starts. And note that we use H2 in-memory database. And you can configure to use a physical database like MySQL, by following this article.
4. Code HATEOAS-driven REST API for Retrieve Operations
For retrieve operation, the HTTP request method should be GET and response status code should be 200 OK for successful operation, or 404 Not Found if the resource not available.
The base URI for Account APIs should be /api/accounts which returns a list of accounts to the client. However, we start from the retrieve operation that gets information of a single account - with the URI /api/accounts/{id} - so you will be able to understand the code from simple to complex.
The initial code is as simple as follows:
package net.codejava.account; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/accounts") public class AccountApi { private AccountService service; public AccountApi(AccountService service) { this.service = service; } @GetMapping("/{id}") public Account getOne(@PathVariable("id") Integer id) { return service.get(id); } }
If you run the command curl localhost:8080/api/accounts/3, you will get the following data of the account ID 3:
{ "id": 3, "accountNumber": "1982094128", "balance": 6211 }
It’s just data only, no links which the client can use to perform actions on the data. So let’s update the code to add the first link that points to the resource itself (with self relation).
Update the entity class to extend the RepresentionalModel abstract class defined by Spring HATEOAS:
public class Account extends RepresentationModel<Account> { // existing code remains unchanged }
Import the RepresentionalModel type from the org.springframework.hateoas package. Then update the getOne() method like this:
@GetMapping("/{id}") public HttpEntity<Account> getOne(@PathVariable("id") Integer id) { Account account = service.get(id); account.add(linkTo(methodOn(AccountApi.class).getOne(id)).withSelfRel()); return new ResponseEntity<>(account, HttpStatus.OK); }
For this to work, you need to add the following additional import statements:
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity;
We use the add() method provided by the RepresentionalModel class to include a link in the JSON response. The linkTo() method creates a link pointing to a controller method, and the methodOn() method tells which method of which controller class will be used to generate the link. And the withSelRef() method specifies the relation of the link is self.
Now, if you call the API again, you will see the data includes a link:
{ "id": 3, "accountNumber": "1982094128", "balance": 6211, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" } } }
You see, the link with relation “self” tells the client that it is the link of the current resource itself.
It’s common to include a link pointing to a collection of accounts. Put the following statement to add the second link with the relation name “collection”:
account.add(linkTo(methodOn(AccountApi.class).listAll()).withRel(IanaLinkRelations.COLLECTION));
Here, we use a relation name defined by IANA (Internet Assigned Numbers Authority) to make the links easily discoverable by REST clients that also use IANA-based relations.
For this to work, you need to implement the getAll() method as follows:
@GetMapping public CollectionModel<Account> listAll() { List<Account> listAccounts = service.listAll(); for (Account account : listAccounts) { account.add(linkTo(methodOn(AccountApi.class).getOne(account.getId())).withSelfRel()); } CollectionModel<Account> collectionModel = CollectionModel.of(listAccounts); collectionModel.add(linkTo(methodOn(AccountApi.class).listAll()).withSelfRel()); return collectionModel; }
Here, you can see we use a CollectionModel object to hold a list of Account objects so we can put links to the JSON response.
Now, if you call the URI /api/accounts, you will see the data looks like the following:
{ "_embedded": { "accountList": [ { "id": 1, "accountNumber": "1982080185", "balance": 1021.99, "_links": { "self": { "href": "http://localhost:8080/api/accounts/1" } } }, { "id": 2, "accountNumber": "1982032177", "balance": 231.5, "_links": { "self": { "href": "http://localhost:8080/api/accounts/2" } } }, { "id": 3, "accountNumber": "1982094128", "balance": 6211, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" } } } ] }, "_links": { "self": { "href": "http://localhost:8080/api/accounts" } } }
As you can see, for each account object there’s a self link that allows the client to get details of that account, and a self link for the collection itself at the end.
Run curl localhost:8080/api/accounts/3, you will get the following data:
{ "id": 3, "accountNumber": "1982094128", "balance": 6211, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
You can see the second link with relation “collection”, right? It tells the client what link should be used to get a collection of items inside the data of a single item.
To catch the exception that is thrown if no account found with the given ID and return status code 404, update the code as follows:
try { Account account = service.get(id); account.add(linkTo(methodOn(AccountApi.class).getOne(id)).withSelfRel()); account.add(linkTo(methodOn(AccountApi.class).listAll()).withRel(IanaLinkRelations.COLLECTION)); return new ResponseEntity<>(account, HttpStatus.OK); } catch (NoSuchElementException ex) { return ResponseEntity.notFound().build(); }
Later I will show you how to add more links related to data of a single account, such as deposit and withdrawal - when the code evolves.
Spring Boot REST APIs Ultimate Course
Hands-on REST API Development with Spring Boot: Design, Implement, Document, Secure, Test, Consume RESTful APIs
5. Code HATEOAS-driven REST API for Create Operation
For create operation, the HTTP request method should be POST and response status code should be 201 Created for successful creation, or 400 Bad Request if the input is invalid. Add the following method into the AccountApi class:
@PostMapping public HttpEntity<Account> add(@RequestBody Account account) { Account savedAccount = service.save(account); account.add(linkTo(methodOn(AccountApi.class) .getOne(savedAccount.getId())).withSelfRel()); account.add(linkTo(methodOn(AccountApi.class) .listAll()).withRel(IanaLinkRelations.COLLECTION)); return ResponseEntity.created(linkTo(methodOn(AccountApi.class) .getOne(savedAccount.getId())).toUri()).body(savedAccount); }
You see, this API returns details of the newly created account. Use the following curl command to test adding an account:
curl -v -H "Content-Type: application/json" -d "{\"accountNumber\": \"112233440099\", \"balance\": 99.99}" localhost:8080/api/accounts
You will see the following output:
< HTTP/1.1 201 < Location: http://localhost:8080/api/accounts/4 < Content-Type: application/hal+json { "id": 4, "accountNumber": "112233440099", "balance": 99.99, "_links": { "self": { "href": "http://localhost:8080/api/accounts/4" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
You see, the status code is 201 Created and the header Location pointing to a URL of the account details. And in JSON data, it includes 2 links with relation self and collection. This allows the client to follow the links to do the next action.
In terms of REST API best practice, you should validate data in the request's payload. Follow this tutorial: Spring Boot REST API Request Validation Examples
6. Code HATEOAS-driven REST API for Full Update operation
For full update operation, the HTTP request method should be PUT and response status code should be 200 OK for successful update operation. So add the following code into the AccountApi class:
@PutMapping public HttpEntity<Account> replace(@RequestBody Account account) { Account updatedAccount = service.save(account); updatedAccount.add(linkTo(methodOn(AccountApi.class) .getOne(updatedAccount.getId())).withSelfRel()); updatedAccount.add(linkTo(methodOn(AccountApi.class) .listAll()).withRel(IanaLinkRelations.COLLECTION)); return new ResponseEntity<>(updatedAccount, HttpStatus.OK); }
As you can see, this API send data that includes details of the updated account. Use the following curl command to test updating the account with ID 3:
curl -X PUT -H "Content-Type: application/json" -d "{\"id\": 3, \"accountNumber\": \"1982094188\", \"balance\": 5950}" localhost:8080/api/accounts
You will see the following JSON response:
{ "id": 3, "accountNumber": "1982094188", "balance": 5950, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
You see, the data also includes HATEOAS links same as the create API.
7. Code HATEOAS-driven REST API for Partial Update operations
Next, I’m going to show you how to implement APIs for deposit and withdrawal, which are partial update operations - only the account balance gets updated.
For partial update operation, the HTTP request method should be PATCH and the response status code is 200 OK for successful update operation. Code the following method for deposit API:
@PatchMapping("/{id}/deposits") public HttpEntity<Account> deposit(@PathVariable("id") Integer id, @RequestBody Amount amount) { Account updatedAccount = service.deposit(amount.getAmount(), id); updatedAccount.add(linkTo(methodOn(AccountApi.class) .getOne(updatedAccount.getId())).withSelfRel()); updatedAccount.add(linkTo(methodOn(AccountApi.class) .listAll()).withRel(IanaLinkRelations.COLLECTION)); return new ResponseEntity<>(updatedAccount, HttpStatus.OK); }
To test this deposit API, you can use the following curl command:
curl -X PATCH -H "Content-Type: application/json" -d "{\"amount\": 50}"} localhost:8080/api/accounts/3/deposits
This adds an amount of 50 to the account ID 3. You will see the data in the response like this:
{ "id": 3, "accountNumber": "1982094128", "balance": 6261, "_links": { "self": { "href": "http://localhost:8080/api/accounts/3" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
You see, only the account balance got updated (increased), right?
Similarly, put the following code for implementing withdrawal API:
@PatchMapping("/{id}/withdrawal") public HttpEntity<Account> withdraw(@PathVariable("id") Integer id, @RequestBody Amount amount) { Account updatedAccount = service.withdraw(amount.getAmount(), id); updatedAccount.add(linkTo(methodOn(AccountApi.class) .getOne(updatedAccount.getId())).withSelfRel()); updatedAccount.add(linkTo(methodOn(AccountApi.class) .listAll()).withRel(IanaLinkRelations.COLLECTION)); return new ResponseEntity<>(updatedAccount, HttpStatus.OK); }
And run the following curl command to withdraw an amount of 100 from the account ID 2:
curl -X PATCH -H "Content-Type: application/json" -d "{\"amount\": 100}"} localhost:8080/api/accounts/2/withdrawal
And observe the data in the response:
{ "id": 2, "accountNumber": "1982032177", "balance": 131.5, "_links": { "self": { "href": "http://localhost:8080/api/accounts/2" }, "collection": { "href": "http://localhost:8080/api/accounts" } } }
You see, only the account balance gets updated (decreased), right?
8. Code REST API for Delete operation
For delete operation, the HTTP request method should be DELETE and the response status code should be 204 No Content for successful deletion. So write the following code for delete account API:
@DeleteMapping("/{id}") public ResponseEntity<?> delete(@PathVariable("id") Integer id) { service.delete(id); return ResponseEntity.noContent().build(); }
You see, this is the only API that doesn’t return any data. For testing, run the following curl command to delete the account ID 1:
curl -v -X DELETE localhost:8080/api/accounts/1
And you will see the status code 204 in the output.
9. Refactor HATEOAS Code
So far we have done coding REST APIs for CRUD operations that follow HATEOAS principle. However, you can notice we have the following code snippet gets duplicated across APIs:
account.add(linkTo(methodOn(AccountApi.class) .getOne(savedAccount.getId())).withSelfRel()); account.add(linkTo(methodOn(AccountApi.class) .listAll()).withRel(IanaLinkRelations.COLLECTION));
Spring HATEOAS provides a kind of assembler class that helps minimizing such duplication, and thus making leaner code. Create a new class named AccountModelAssembler with the following code:
package net.codejava.account; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.server.RepresentationModelAssembler; import org.springframework.stereotype.Component; @Component public class AccountModelAssembler implements RepresentationModelAssembler<Account, EntityModel<Account>> { @Override public EntityModel<Account> toModel(Account entity) { EntityModel<Account> accountModel = EntityModel.of(entity); accountModel.add(linkTo(methodOn(AccountApi.class).getOne(entity.getId())).withSelfRel()); accountModel.add(linkTo(methodOn(AccountApi.class).listAll()).withRel(IanaLinkRelations.COLLECTION)); return accountModel; } }
Then update the controller class to use model assembler as follows:
@RestController @RequestMapping("/api/accounts") public class AccountApi { private AccountService service; private AccountModelAssembler modelAssembler; public AccountApi(AccountService service, AccountModelAssembler modelAssembler) { this.service = service; this.modelAssembler = modelAssembler; } @GetMapping("/{id}") public ResponseEntity<EntityModel<Account>> getOne(@PathVariable("id") Integer id) { try { Account account = service.get(id); EntityModel<Account> model = modelAssembler.toModel(account); return new ResponseEntity<>(model, HttpStatus.OK); } catch (NoSuchElementException ex) { return ResponseEntity.notFound().build(); } } }
You see, now we can use modelAssembler.toModel() method to convert a JPA entity class to an EntityModel class that includes HATEOAS links. And we don’t need to have our entity classes extend RepresentationModel anymore.
Then update the listAll() method as follows:
@GetMapping public CollectionModel<EntityModel<Account>> listAll() { List<EntityModel<Account>> listEntityModel = service.listAll() .stream().map(modelAssembler::toModel).collect(Collectors.toList()); CollectionModel<EntityModel<Account>> collectionModel = CollectionModel.of(listEntityModel); collectionModel.add(linkTo(methodOn(AccountApi.class).listAll()).withSelfRel()); return collectionModel; }
Next, update the create API as below:
@PostMapping public HttpEntity<EntityModel<Account>> add(@RequestBody @Valid Account account) { Account savedAccount = service.save(account); EntityModel<Account> model = modelAssembler.toModel(savedAccount); return ResponseEntity.created(linkTo(methodOn(AccountApi.class) .getOne(savedAccount.getId())).toUri()).body(model); }
And update the full update API as follows:
@PutMapping public HttpEntity<EntityModel<Account>> replace(@RequestBody Account account) { Account updatedAccount = service.save(account); return new ResponseEntity<>(modelAssembler.toModel(updatedAccount), HttpStatus.OK); }
Similarly, update the deposit API:
@PatchMapping("/{id}/deposits") public HttpEntity<EntityModel<Account>> deposit(@PathVariable("id") Integer id, @RequestBody Amount amount) { Account updatedAccount = service.deposit(amount.getAmount(), id); return new ResponseEntity<>(modelAssembler.toModel(updatedAccount), HttpStatus.OK); }
And update the withdrawal API:
@PatchMapping("/{id}/withdrawal") public HttpEntity<EntityModel<Account>> withdraw(@PathVariable("id") Integer id, @RequestBody Amount amount) { Account updatedAccount = service.withdraw(amount.getAmount(), id); return new ResponseEntity<>(modelAssembler.toModel(updatedAccount), HttpStatus.OK); }
Finally, we add two links for deposit and withdrawal of an account. So update the toModel() method of the AccountModelAssembler class as follows:
public EntityModel<Account> toModel(Account entity) { EntityModel<Account> accountModel = EntityModel.of(entity); accountModel.add(linkTo(methodOn(AccountApi.class).getOne(entity.getId())).withSelfRel()); accountModel.add(linkTo(methodOn(AccountApi.class).listAll()).withRel(IanaLinkRelations.COLLECTION)); accountModel.add(linkTo(methodOn(AccountApi.class).deposit(entity.getId(), null)).withRel("deposits")); accountModel.add(linkTo(methodOn(AccountApi.class).withdraw(entity.getId(), null)).withRel("withdrawal")); return accountModel; }
Now, if you test the retrieve API /api/accounts/1, you will get the following data:
{ "id": 1, "accountNumber": "1982080185", "balance": 1021.99, "_links": { "self": { "href": "http://localhost:8080/api/accounts/1" }, "collection": { "href": "http://localhost:8080/api/accounts" }, "deposits": { "href": "http://localhost:8080/api/accounts/1/deposits" }, "withdrawal": { "href": "http://localhost:8080/api/accounts/1/withdrawal" } } }
You see, with these links included in data of an account, the client can follow to perform desired actions.
10. Add arbitrary links
You can add an arbitrary link to an entity model object like this:
entityModel.add(Link.of("https://company.xyz", "Ref"));
The following line adds an arbitrary link to a collection model:
collectionModel.add(Link.of("http://company.com/api/docs", "docs"));
And add a link with relation follows IANA-based link relation:
collectionModel.add(Link.of("http://company.com/namhaminh", IanaLinkRelations.AUTHOR));
That’s my tutorial about developing RESTful APIs with Spring Boot and Spring HATEOAS. You’re now able to implement your own REST APIs that follow HATEOAS principles. You can download the sample project code in the Attachments section below, or clone the sample project code from this GitHub repo. And to see the coding in action, I recommend you watch the following video:
Recommended Course:
References:
Related REST API Tutorials:
- REST API Best Practices: How to Use the Right HTTP Methods and Status Codes
- How to Use curl for Testing REST APIs (Test CRUD Operations)
- Java RESTful Web Services Tutorial for Beginner with Jersey and Tomcat
- Java CRUD RESTful Web Services Examples with Jersey and Tomcat
- Spring Boot Hello World RESTful Web Services Tutorial
- Spring Boot RESTful CRUD API Examples with MySQL database
- Spring Boot File Download and Upload REST API Examples
- Spring Boot REST API Request Validation Examples
Comments
I like using this, or lombok @RequiredArgsConstructor
private final AccountRepository accountRepo;
public LoadDatabase(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}