Spring REST HATEOAS
Introduction to HATEOAS
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of the REST application architecture that elevates your REST APIs from simple CRUD operations to truly RESTful services. The core principle behind HATEOAS is that a client interacts with a network application entirely through hypermedia provided dynamically by application servers. In simpler terms, when you request a resource, the server not only sends the data but also sends links to related actions you can perform with that resource.
Think of HATEOAS like browsing a website: you don't need to know all the URLs in advance—you discover them through links on the pages you visit. Similarly, HATEOAS enables REST clients to navigate APIs dynamically through hyperlinks included in responses.
Spring provides excellent support for HATEOAS through the Spring HATEOAS project, making it easy to create hypermedia-driven REST services.
Why Use HATEOAS?
Before diving into implementation, let's understand why HATEOAS is valuable:
- Self-descriptive APIs: Clients can understand what actions they can perform without prior knowledge
- Reduced coupling: Clients don't need to hardcode URI patterns
- API evolution: Server can change URIs without breaking clients
- Discoverability: New features can be discovered by clients automatically
- Better documentation: The API inherently documents itself through links
Setting Up Spring HATEOAS
To get started with Spring HATEOAS, add the following dependency to your project:
Maven:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
Gradle:
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
Key Components of Spring HATEOAS
Spring HATEOAS provides several classes to help build hypermedia-driven responses:
EntityModel
: Wraps domain objects and allows adding links to themCollectionModel
: Wraps collections of resourcesPagedModel
: Specifically designed for paginated resourcesLink
: Represents a hypermedia linkWebMvcLinkBuilder
: Helper class to easily create links to controller methods
Basic HATEOAS Implementation
Let's create a simple REST API for a customer management system with HATEOAS support:
Step 1: Define the Domain Model
First, let's create a simple Customer class:
public class Customer {
private Long id;
private String firstName;
private String lastName;
private String email;
// Constructors, getters, and setters
public Customer() {}
public Customer(Long id, String firstName, String lastName, String email) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Step 2: Create a Controller with HATEOAS Support
Now let's create a REST controller that incorporates HATEOAS principles:
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.Link;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
// In a real application, this would come from a database
private List<Customer> customers = Arrays.asList(
new Customer(1L, "John", "Doe", "[email protected]"),
new Customer(2L, "Jane", "Smith", "[email protected]"),
new Customer(3L, "Mike", "Johnson", "[email protected]")
);
@GetMapping
public CollectionModel<EntityModel<Customer>> getAllCustomers() {
List<EntityModel<Customer>> customerResources = customers.stream()
.map(customer -> EntityModel.of(customer,
linkTo(methodOn(CustomerController.class).getCustomerById(customer.getId())).withSelfRel(),
linkTo(methodOn(CustomerController.class).getAllCustomers()).withRel("customers")
))
.collect(Collectors.toList());
return CollectionModel.of(customerResources,
linkTo(methodOn(CustomerController.class).getAllCustomers()).withSelfRel());
}
@GetMapping("/{id}")
public EntityModel<Customer> getCustomerById(@PathVariable Long id) {
// In a real application, you would check if the customer exists and handle errors
Customer customer = customers.stream()
.filter(c -> c.getId().equals(id))
.findFirst()
.orElseThrow(() -> new RuntimeException("Customer not found"));
return EntityModel.of(customer,
linkTo(methodOn(CustomerController.class).getCustomerById(id)).withSelfRel(),
linkTo(methodOn(CustomerController.class).getAllCustomers()).withRel("customers"),
linkTo(methodOn(OrderController.class).getOrdersForCustomer(id)).withRel("orders")
);
}
}
Step 3: Create a Related Resource Controller
Let's assume customers have orders, so we'll create an OrderController to demonstrate linking between related resources:
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// Mock data - in a real application, this would come from a database
private Map<Long, List<Order>> customerOrders = new HashMap<>();
public OrderController() {
// Initialize with some sample data
customerOrders.put(1L, Arrays.asList(
new Order(101L, "Product A", 99.99),
new Order(102L, "Product B", 149.99)
));
customerOrders.put(2L, Arrays.asList(
new Order(103L, "Product C", 59.99)
));
customerOrders.put(3L, Arrays.asList(
new Order(104L, "Product D", 199.99),
new Order(105L, "Product E", 29.99)
));
}
@GetMapping("/customer/{customerId}")
public CollectionModel<EntityModel<Order>> getOrdersForCustomer(@PathVariable Long customerId) {
List<Order> orders = customerOrders.getOrDefault(customerId, Collections.emptyList());
List<EntityModel<Order>> orderResources = orders.stream()
.map(order -> EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrderById(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class).getOrdersForCustomer(customerId)).withRel("customerOrders"),
linkTo(methodOn(CustomerController.class).getCustomerById(customerId)).withRel("customer")
))
.collect(Collectors.toList());
return CollectionModel.of(orderResources,
linkTo(methodOn(OrderController.class).getOrdersForCustomer(customerId)).withSelfRel(),
linkTo(methodOn(CustomerController.class).getCustomerById(customerId)).withRel("customer")
);
}
@GetMapping("/{id}")
public EntityModel<Order> getOrderById(@PathVariable Long id) {
// In a real application, you would look up the order in a database
Order order = findOrderById(id);
Long customerId = findCustomerIdForOrder(id);
return EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrderById(id)).withSelfRel(),
linkTo(methodOn(CustomerController.class).getCustomerById(customerId)).withRel("customer"),
linkTo(methodOn(OrderController.class).getOrdersForCustomer(customerId)).withRel("customerOrders")
);
}
// Helper methods for sample implementation
private Order findOrderById(Long id) {
for (Map.Entry<Long, List<Order>> entry : customerOrders.entrySet()) {
Optional<Order> order = entry.getValue().stream()
.filter(o -> o.getId().equals(id))
.findFirst();
if (order.isPresent()) {
return order.get();
}
}
throw new RuntimeException("Order not found");
}
private Long findCustomerIdForOrder(Long orderId) {
for (Map.Entry<Long, List<Order>> entry : customerOrders.entrySet()) {
boolean hasOrder = entry.getValue().stream()
.anyMatch(order -> order.getId().equals(orderId));
if (hasOrder) {
return entry.getKey();
}
}
throw new RuntimeException("Customer not found for order");
}
// Order class (would be in its own file in a real application)
public static class Order {
private Long id;
private String productName;
private double amount;
public Order(Long id, String productName, double amount) {
this.id = id;
this.productName = productName;
this.amount = amount;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; }
}
}
Understanding the Response Structure
When you access GET /api/customers/1
, you'll receive a response like this:
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/api/customers/1"
},
"customers": {
"href": "http://localhost:8080/api/customers"
},
"orders": {
"href": "http://localhost:8080/api/orders/customer/1"
}
}
}
Notice how the response includes:
- The customer data itself
- A
self
link pointing to this resource - A link to all customers (the collection resource)
- A link to the customer's orders (a related resource)
This makes the API self-descriptive and navigable.
Advanced Features
Custom Link Relations
Spring HATEOAS allows you to define custom link relations by implementing the LinkRelation
interface or using the static LinkRelation.of()
method:
import org.springframework.hateoas.LinkRelation;
// In your controller
EntityModel.of(customer,
linkTo(methodOn(CustomerController.class).getCustomerById(id)).withSelfRel(),
linkTo(methodOn(CustomerController.class).getAllCustomers()).withRel("customers"),
linkTo(methodOn(CustomerController.class).updateCustomer(id, null)).withRel(LinkRelation.of("update")),
linkTo(methodOn(CustomerController.class).deleteCustomer(id)).withRel(LinkRelation.of("delete"))
);
Affordances (for forms)
Affordances extend links with information about the HTTP methods that can be used:
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import org.springframework.hateoas.mediatype.Affordances;
EntityModel<Customer> customerModel = EntityModel.of(customer);
Link selfLink = linkTo(methodOn(CustomerController.class).getCustomerById(id)).withSelfRel();
Link affordanceLink = Affordances.of(selfLink)
.afford(HttpMethod.PUT)
.withInput(Customer.class)
.withTarget(methodOn(CustomerController.class).updateCustomer(id, null))
.toLink();
customerModel.add(affordanceLink);
Conditional Links
You might want to include certain links only under specific conditions:
EntityModel<Customer> customerModel = EntityModel.of(customer);
// Always include self link
customerModel.add(linkTo(methodOn(CustomerController.class).getCustomerById(id)).withSelfRel());
// Only add delete link for non-premium customers
if (!customer.isPremiumCustomer()) {
customerModel.add(linkTo(methodOn(CustomerController.class).deleteCustomer(id)).withRel("delete"));
}
// Only add upgrade link for eligible customers
if (customer.isEligibleForUpgrade()) {
customerModel.add(linkTo(methodOn(CustomerController.class).upgradeCustomer(id)).withRel("upgrade"));
}
Best Practices for Spring HATEOAS
- Be consistent with link relations: Use standard IANA link relations when possible (like
self
,next
,prev
) - Don't leak implementation details: Links should be stable and not reveal implementation details
- Provide meaningful link relations: Use descriptive names that help clients understand what the link does
- Use URI templates for query parameters: When appropriate, let clients know how to construct parameterized requests
- Keep representations lightweight: Don't include unnecessary links that bloat responses
- Document your link relations: If you use custom link relations, document them clearly
Real-world Application: RESTful E-commerce API
Let's see a more comprehensive example of using HATEOAS in an e-commerce API. We'll focus on a product resource that can be browsed, searched, reviewed, and purchased:
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public CollectionModel<EntityModel<Product>> getAllProducts(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
// Get paginated product list (in a real app, from a database)
Page<Product> productPage = productService.findProducts(category, PageRequest.of(page, size));
List<EntityModel<Product>> productModels = productPage.getContent().stream()
.map(product -> toModel(product))
.collect(Collectors.toList());
PagedModel.PageMetadata metadata = new PagedModel.PageMetadata(
productPage.getSize(),
productPage.getNumber(),
productPage.getTotalElements(),
productPage.getTotalPages());
PagedModel<EntityModel<Product>> pagedModel = PagedModel.of(productModels, metadata);
// Add navigation links
if (productPage.hasNext()) {
pagedModel.add(linkTo(methodOn(ProductController.class)
.getAllProducts(category, page + 1, size))
.withRel("next"));
}
if (productPage.hasPrevious()) {
pagedModel.add(linkTo(methodOn(ProductController.class)
.getAllProducts(category, page - 1, size))
.withRel("prev"));
}
// Add link to first and last pages
pagedModel.add(linkTo(methodOn(ProductController.class)
.getAllProducts(category, 0, size))
.withRel("first"));
pagedModel.add(linkTo(methodOn(ProductController.class)
.getAllProducts(category, productPage.getTotalPages() - 1, size))
.withRel("last"));
return pagedModel;
}
@GetMapping("/{id}")
public EntityModel<Product> getProduct(@PathVariable Long id) {
Product product = productService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
return toModel(product);
}
private EntityModel<Product> toModel(Product product) {
EntityModel<Product> productModel = EntityModel.of(product);
// Basic links
productModel.add(
linkTo(methodOn(ProductController.class).getProduct(product.getId())).withSelfRel(),
linkTo(methodOn(ProductController.class).getAllProducts(null, 0, 10)).withRel("products")
);
// Add link to reviews
productModel.add(
linkTo(methodOn(ReviewController.class).getReviewsForProduct(product.getId())).withRel("reviews")
);
// Add link to related products
productModel.add(
linkTo(methodOn(ProductController.class).getRelatedProducts(product.getId())).withRel("related")
);
// Conditional links based on product state
if (product.isInStock()) {
productModel.add(
linkTo(methodOn(OrderController.class).addToCart(product.getId(), null)).withRel("addToCart")
);
}
if (product.isOnSale()) {
productModel.add(
linkTo(methodOn(PromotionController.class).getProductPromotions(product.getId())).withRel("promotions")
);
}
return productModel;
}
@GetMapping("/{id}/related")
public CollectionModel<EntityModel<Product>> getRelatedProducts(@PathVariable Long id) {
// Implementation omitted for brevity
// Would return related products based on category, purchase history, etc.
}
}
This example demonstrates several advanced HATEOAS concepts:
- Pagination links (next, prev, first, last)
- Conditional links based on product state (in stock, on sale)
- Related resource links to reviews, related products, and promotions
- Action links for operations like adding to cart
- Collection links to access lists of resources
Summary
Spring HATEOAS transforms traditional REST APIs into truly RESTful services by making them dynamically navigable through hypermedia links. The key benefits include:
- Reduced client-server coupling
- Self-documenting APIs
- Improved discoverability
- Support for API evolution
By implementing HATEOAS in your Spring REST applications, you're building more robust and flexible APIs that adhere more closely to REST principles. The Spring HATEOAS library provides a rich set of tools to help you add hypermedia to your resources with minimal boilerplate code.
Additional Resources
- Spring HATEOAS Reference Documentation
- Understanding HATEOAS
- REST API Design - Resource Modeling
- Richardson Maturity Model
Exercises
- Create a simple blog API with
Post
andComment
resources that implement HATEOAS principles - Add pagination to your blog post list endpoint with appropriate navigation links
- Implement conditional links on posts based on whether the post is published or draft
- Add affordances to your API to allow clients to understand how to create, update and delete resources
- Create a custom media type for your API that includes documentation about available link relations
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)