HTTP Topic
HATEOAS - Building Self-Discoverable REST APIs
What You'll Learn:
-
Understanding HATEOAS principles and benefits
-
Implementing HATEOAS in Spring Boot applications
-
Designing hypermedia-driven APIs
-
Client development with HATEOAS
Detailed Explanation:
What is HATEOAS?
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of the REST application architecture that distinguishes RESTful from other HTTP-based systems. A client interacts with a network application entirely through hypermedia provided dynamically by application servers.
Key Principles:
-
Hypermedia-driven: Clients navigate APIs through links in responses
-
Self-discoverable: APIs describe their own capabilities
-
Decoupled: Clients don't need prior knowledge of API structure
-
State-driven: Application state determines available actions
Without HATEOAS vs With HATEOAS
Traditional REST API (Without HATEOAS):
json
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"status": "active"
}
Client needs to know:
-
How to update this user
-
How to delete this user
-
How to find user's orders
-
What actions are available based on user status
HATEOAS-enabled API:
json
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"_links": {
"self": { "href": "/api/users/123" },
"update": { "href": "/api/users/123" },
"delete": { "href": "/api/users/123" },
"orders": { "href": "/api/users/123/orders" },
"deactivate": { "href": "/api/users/123/deactivate" }
}
}
Client discovers actions from the response itself
Spring HATEOAS Implementation
Dependencies:
xml
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Basic Entity Model:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String status;
// Constructors, getters, setters
public User() {}
public User(String name, String email) {
this.name = name;
this.email = email;
this.status = "active";
}
}
Implementing HATEOAS in Controllers
1. Single Resource with Links:
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
User user = userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(UserController.class).updateUser(id, null)).withRel("update"),
linkTo(methodOn(UserController.class).deleteUser(id)).withRel("delete"),
linkTo(methodOn(UserController.class).getUserOrders(id)).withRel("orders")
);
}
// Helper method to get controller class reference
private UserController getController() {
return methodOn(UserController.class);
}
}
2. Collection Resource with Pagination:
@GetMapping
public CollectionModel<EntityModel<User>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> userPage = userService.findAll(PageRequest.of(page, size));
List<EntityModel<User>> userModels = userPage.getContent()
.stream()
.map(user -> EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()
))
.collect(Collectors.toList());
// Create pagination links
Link selfLink = linkTo(methodOn(UserController.class)
.getUsers(page, size)).withSelfRel();
CollectionModel<EntityModel<User>> collectionModel =
CollectionModel.of(userModels, selfLink);
// Add pagination links
if (userPage.hasNext()) {
Link nextLink = linkTo(methodOn(UserController.class)
.getUsers(page + 1, size)).withRel("next");
collectionModel.add(nextLink);
}
if (page > 0) {
Link prevLink = linkTo(methodOn(UserController.class)
.getUsers(page - 1, size)).withRel("prev");
collectionModel.add(prevLink);
}
Link firstLink = linkTo(methodOn(UserController.class)
.getUsers(0, size)).withRel("first");
collectionModel.add(firstLink);
if (userPage.getTotalPages() > 0) {
Link lastLink = linkTo(methodOn(UserController.class)
.getUsers(userPage.getTotalPages() - 1, size)).withRel("last");
collectionModel.add(lastLink);
}
return collectionModel;
}
Advanced HATEOAS Patterns
1. Conditional Links Based on Business Logic:
@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
User user = userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
EntityModel<User> model = EntityModel.of(user);
// Always add self link
model.add(linkTo(methodOn(UserController.class).getUser(id)).withSelfRel());
// Conditional links based on user status
if ("active".equals(user.getStatus())) {
model.add(linkTo(methodOn(UserController.class).deactivateUser(id)).withRel("deactivate"));
model.add(linkTo(methodOn(UserController.class).suspendUser(id)).withRel("suspend"));
} else if ("suspended".equals(user.getStatus())) {
model.add(linkTo(methodOn(UserController.class).activateUser(id)).withRel("activate"));
}
// Role-based links
if (hasPermission("DELETE_USER")) {
model.add(linkTo(methodOn(UserController.class).deleteUser(id)).withRel("delete"));
}
// Relationship links
model.add(linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders"));
model.add(linkTo(methodOn(AddressController.class).getUserAddresses(id)).withRel("addresses"));
return model;
}
2. Custom Link Relations:
public class CustomLinkRelations {
public static final LinkRelation ACTIVATE = LinkRelation.of("activate");
public static final LinkRelation DEACTIVATE = LinkRelation.of("deactivate");
public static final LinkRelation SUSPEND = LinkRelation.of("suspend");
}
// Usage in controller
model.add(linkTo(methodOn(UserController.class).activateUser(id))
.withRel(CustomLinkRelations.ACTIVATE));
Representation Model Assemblers
User Resource Assembler:
@Component
public class UserResourceAssembler
implements RepresentationModelAssembler<User, EntityModel<User>> {
@Override
public EntityModel<User> toModel(User user) {
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel(),
linkTo(methodOn(UserController.class).updateUser(user.getId(), null)).withRel("update"),
linkTo(methodOn(UserController.class).getUserOrders(user.getId())).withRel("orders")
);
}
@Override
public CollectionModel<EntityModel<User>> toCollectionModel(
Iterable<? extends User> entities) {
List<EntityModel<User>> userModels = StreamSupport
.stream(entities.spliterator(), false)
.map(this::toModel)
.collect(Collectors.toList());
return CollectionModel.of(userModels,
linkTo(methodOn(UserController.class).getUsers(0, 10)).withSelfRel()
);
}
public EntityModel<User> toDetailedModel(User user) {
EntityModel<User> model = toModel(user);
// Add additional links for detailed view
model.add(linkTo(methodOn(UserController.class).getUserPermissions(user.getId()))
.withRel("permissions"));
model.add(linkTo(methodOn(UserController.class).getUserSettings(user.getId()))
.withRel("settings"));
return model;
}
}
Using Assembler in Controller:
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserResourceAssembler userAssembler;
@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
User user = userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return userAssembler.toModel(user);
}
@GetMapping
public CollectionModel<EntityModel<User>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> users = userService.findAll(PageRequest.of(page, size));
return userAssembler.toCollectionModel(users);
}
}
HAL (Hypertext Application Language) Format
HAL Response Example:
json
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"status": "active",
"_links": {
"self": {
"href": "http://localhost:8080/api/users/123"
},
"update": {
"href": "http://localhost:8080/api/users/123"
},
"delete": {
"href": "http://localhost:8080/api/users/123"
},
"orders": {
"href": "http://localhost:8080/api/users/123/orders"
},
"deactivate": {
"href": "http://localhost:8080/api/users/123/deactivate"
}
},
"_embedded": {
"latestOrders": [
{
"id": 456,
"total": 99.99,
"status": "shipped",
"_links": {
"self": {
"href": "http://localhost:8080/api/orders/456"
}
}
}
]
}
}
Implementing HAL with Embedded Resources:
@GetMapping("/{id}/with-orders")
public EntityModel<User> getUserWithOrders(@PathVariable Long id) {
User user = userService.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
List<Order> recentOrders = orderService.findRecentOrdersByUser(id, 3);
EntityModel<User> userModel = userAssembler.toModel(user);
// Convert orders to HAL models
List<EntityModel<Order>> orderModels = recentOrders.stream()
.map(order -> EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrder(order.getId())).withSelfRel()
))
.collect(Collectors.toList());
// Add embedded orders
userModel.add(CollectionModel.of(orderModels).withRel("latestOrders"));
return userModel;
}
API Root with Discovery
API Root Controller:
@RestController
@RequestMapping("/api")
public class ApiRootController {
@GetMapping
public EntityModel<ApiRoot> getApiRoot() {
ApiRoot apiRoot = new ApiRoot("User Management API", "1.0.0");
return EntityModel.of(apiRoot,
linkTo(methodOn(UserController.class).getUsers(0, 10)).withRel("users"),
linkTo(methodOn(OrderController.class).getOrders(0, 10)).withRel("orders"),
linkTo(methodOn(ProductController.class).getProducts(0, 10)).withRel("products"),
linkTo(methodOn(ApiRootController.class).getApiRoot()).withSelfRel()
);
}
public static class ApiRoot {
private String name;
private String version;
private String description = "Hypermedia-driven REST API";
public ApiRoot(String name, String version) {
this.name = name;
this.version = version;
}
// getters and setters
}
}
API Root Response:
json
{
"name": "User Management API",
"version": "1.0.0",
"description": "Hypermedia-driven REST API",
"_links": {
"self": {
"href": "http://localhost:8080/api"
},
"users": {
"href": "http://localhost:8080/api/users?page=0&size=10"
},
"orders": {
"href": "http://localhost:8080/api/orders?page=0&size=10"
},
"products": {
"href": "http://localhost:8080/api/products?page=0&size=10"
}
}
}
Advanced HATEOAS Features
1. Templated Links:
@GetMapping("/search")
public CollectionModel<EntityModel<User>> searchUsers(
@RequestParam String name,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> users = userService.searchUsers(name, email, PageRequest.of(page, size));
Link selfLink = linkTo(methodOn(UserController.class)
.searchUsers(name, email, page, size))
.withSelfRel();
// Templated link for search with different parameters
Link searchTemplate = Link.of("/api/users/search{?name,email,page,size}")
.withRel("search");
return CollectionModel.of(
userAssembler.toCollectionModel(users),
selfLink,
searchTemplate
);
}
2. Link Builders with Custom Parameters:
public class UserLinkBuilder {
public static Link getUserLink(Long userId) {
return linkTo(methodOn(UserController.class).getUser(userId)).withSelfRel();
}
public static Link getUsersLink(int page, int size) {
return linkTo(methodOn(UserController.class).getUsers(page, size)).withRel("users");
}
public static Link getUpdateUserLink(Long userId) {
return linkTo(methodOn(UserController.class).updateUser(userId, null))
.withRel("update");
}
public static Link getDeleteUserLink(Long userId) {
return linkTo(methodOn(UserController.class).deleteUser(userId))
.withRel("delete")
.withTitle("Delete User");
}
}
// Usage in controller
model.add(UserLinkBuilder.getUserLink(user.getId()));
model.add(UserLinkBuilder.getUpdateUserLink(user.getId()));
Testing HATEOAS APIs
Controller Unit Tests:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserWithLinks() throws Exception {
User user = new User(1L, "John Doe", "john@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$._links.self.href").exists())
.andExpect(jsonPath("$._links.update.href").exists())
.andExpect(jsonPath("$._links.orders.href").exists());
}
@Test
void shouldReturnUsersCollectionWithPaginationLinks() throws Exception {
Page<User> userPage = new PageImpl<>(
Arrays.asList(
new User(1L, "John Doe", "john@example.com"),
new User(2L, "Jane Smith", "jane@example.com")
),
PageRequest.of(0, 10),
2
);
when(userService.findAll(any(Pageable.class))).thenReturn(userPage);
mockMvc.perform(get("/api/users?page=0&size=10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$._links.self.href").exists())
.andExpect(jsonPath("$._links.first.href").exists())
.andExpect(jsonPath("$._links.last.href").exists());
}
}
Integration Tests:
java
@SpringBootTest
@AutoConfigureTestDatabase
class UserHateoasIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldFollowLinksFromApiRoot() {
// Start from API root
ResponseEntity<Map> rootResponse = restTemplate.getForEntity("/api", Map.class);
Map<String, Object> rootBody = rootResponse.getBody();
// Extract users link
Map<String, Object> links = (Map<String, Object>) rootBody.get("_links");
Map<String, Object> usersLink = (Map<String, Object>) links.get("users");
String usersUrl = (String) usersLink.get("href");
// Follow users link
ResponseEntity<Map> usersResponse = restTemplate.getForEntity(usersUrl, Map.class);
assertThat(usersResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// Verify users response has expected structure
Map<String, Object> usersBody = usersResponse.getBody();
assertThat(usersBody).containsKey("_embedded");
assertThat(usersBody).containsKey("_links");
}
}
Client-Side HATEOAS Implementation
JavaScript HATEOAS Client:
class HateoasClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.currentState = null;
}
async navigateTo(url) {
const response = await fetch(url);
const data = await response.json();
this.currentState = data;
return data;
}
async followRel(rel) {
if (!this.currentState || !this.currentState._links[rel]) {
throw new Error(`Link relation '${rel}' not found`);
}
const url = this.currentState._links[rel].href;
return await this.navigateTo(url);
}
async performAction(rel, method = 'GET', body = null) {
if (!this.currentState || !this.currentState._links[rel]) {
throw new Error(`Link relation '${rel}' not found`);
}
const url = this.currentState._links[rel].href;
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
return await response.json();
}
getAvailableActions() {
if (!this.currentState || !this.currentState._links) {
return [];
}
return Object.keys(this.currentState._links)
.filter(rel => rel !== 'self');
}
}
// Usage example
const client = new HateoasClient('http://localhost:8080/api');
// Start from API root
await client.navigateTo(client.baseUrl);
// Discover and follow users link
await client.followRel('users');
// Get first user from collection
const users = client.currentState._embedded.users;
if (users.length > 0) {
await client.navigateTo(users[0]._links.self.href);
// Check available actions for this user
const actions = client.getAvailableActions();
console.log('Available actions:', actions);
// Perform an action if available
if (actions.includes('deactivate')) {
await client.performAction('deactivate', 'POST');
}
}
Best Practices for HATEOAS APIs
1. Consistent Link Relations:
-
Use standard relations:
self,first,last,next,prev -
Define custom relations clearly
-
Document link relations in API documentation
2. Proper Error Handling with Links:
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<EntityModel<ErrorResponse>> handleUserNotFound(UserNotFoundException ex) {
ErrorResponse error = new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
EntityModel<ErrorResponse> errorModel = EntityModel.of(error,
linkTo(methodOn(UserController.class).getUsers(0, 10)).withRel("users"),
linkTo(methodOn(ApiRootController.class).getApiRoot()).withRel("home")
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorModel);
}
3. Versioning with HATEOAS:
@GetMapping(value = "/{id}", produces = "application/vnd.company.user.v1+json")
public EntityModel<UserV1> getUserV1(@PathVariable Long id) {
User user = userService.findById(id).orElseThrow();
UserV1 userV1 = userMapper.toV1(user);
return EntityModel.of(userV1,
linkTo(methodOn(UserController.class).getUserV1(id)).withSelfRel(),
linkTo(methodOn(UserController.class).getUserV2(id)).withRel("alternate")
);
}
4. Caching Considerations:
-
Links should reflect current state
-
Use ETags for conditional requests
-
Consider cacheability of linked resources
Real-World HATEOAS Example
E-commerce API Flow:
// 1. Start from API root
@GetMapping("/api")
public EntityModel<ApiRoot> getApiRoot() {
return EntityModel.of(new ApiRoot("E-commerce API", "1.0"),
linkTo(methodOn(ProductController.class).getProducts(0, 20)).withRel("products"),
linkTo(methodOn(CategoryController.class).getCategories()).withRel("categories"),
linkTo(methodOn(CartController.class).getCart()).withRel("cart")
);
}
// 2. Product listing with search and filter links
@GetMapping("/api/products")
public CollectionModel<EntityModel<Product>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String category) {
Page<Product> products = productService.findProducts(category, PageRequest.of(page, size));
CollectionModel<EntityModel<Product>> model = productAssembler.toCollectionModel(products);
// Add search and filter links
model.add(linkTo(methodOn(ProductController.class)
.searchProducts(null, null, 0, 20)).withRel("search"));
model.add(Link.of("/api/products{?category,page,size}").withRel("filter"));
return model;
}
// 3. Individual product with add-to-cart action
@GetMapping("/api/products/{id}")
public EntityModel<Product> getProduct(@PathVariable Long id) {
Product product = productService.findById(id).orElseThrow();
EntityModel<Product> model = productAssembler.toModel(product);
// Add cart action if product is available
if (product.getStock() > 0) {
model.add(linkTo(methodOn(CartController.class).addToCart(null)).withRel("add-to-cart"));
}
// Add related products
List<Product> related = productService.findRelatedProducts(id);
if (!related.isEmpty()) {
model.add(CollectionModel.of(
productAssembler.toCollectionModel(related)
).withRel("related-products"));
}
return model;
}
This extensive HATEOAS tutorial covers everything from basic concepts to advanced implementation patterns, providing you with the knowledge to build truly RESTful, self-discoverable APIs that clients can navigate dynamically without prior knowledge of the API structure.
Comments (0)
No comments yet. Be the first to comment!