Menu

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!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?