Beyond the Defaults: Crafting Custom _links Structures with Spring HATEOAS
Spring HATEOAS is a powerful tool for building RESTful APIs that embrace the principles of Hypermedia as the Engine of Application State (HATEOAS). By including hypermedia links within your API responses, you enable clients to discover and navigate the API dynamically, reducing the need for hardcoded URLs and fostering a more decoupled and evolvable system.
Out of the box, Spring HATEOAS provides a sensible default structure for the _links section of your responses. Typically, you’ll see links with standard relation names like self, next, prev, and potentially custom relations based on the relationships between your resources. However, there are often scenarios where the default structure doesn’t quite align with your API’s specific needs or your client’s expectations. This is where the ability to customize the _links structure becomes crucial.
This article will explore various techniques for tailoring the _links section in your Spring HATEOAS responses, providing practical examples and delving into the underlying mechanisms that make this customization possible. We’ll move beyond simple examples and aim for a comprehensive understanding, ensuring you have the knowledge to craft _links that perfectly suit your API’s unique characteristics.
Understanding the Default _links Structure
Before we delve into customization, it’s essential to understand the default structure that Spring HATEOAS employs. When you use classes like RepresentationModel, EntityModel, or PagedModel and add Link instances to them, Spring HATEOAS typically serializes these links into a JSON object nested under the _links key. Each link within this object is represented by a key corresponding to the link’s relation (rel) and a value that is either a simple href string or a more detailed object containing the href and potentially other properties like templated or type.
For instance, a simple representation of a user resource might look like this:
{
"id": 123,
"username": "john.doe",
"email": "john.doe@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/users/123"
}
}
}
In a paged response, you might see:
{
"_embedded": {
"users": [
{
"id": 123,
"username": "john.doe",
"_links": {
"self": {
"href": "http://localhost:8080/users/123"
}
}
},
{
"id": 456,
"username": "jane.doe",
"_links": {
"self": {
"href": "http://localhost:8080/users/456"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/users?page=0&size=2"
},
"next": {
"href": "http://localhost:8080/users?page=1&size=2"
},
"last": {
"href": "http://localhost:8080/users?page=10&size=2"
}
},
"page": {
"size": 2,
"totalElements": 21,
"totalPages": 11,
"number": 0
}
}
While this default structure is often sufficient, there are scenarios where you might want to:
- Group related links: Organize links based on their purpose or the resources they point to.
- Embed additional metadata: Include more information within the link objects beyond just the
href. - Flatten the
_linksstructure: Integrate links directly into the main resource representation for simplicity in certain cases. - Use different naming conventions: Align link relation names with specific client requirements or industry standards.
Method 1: Leveraging Link Builders for Relation Customization
The most fundamental way to influence the _links structure is through the LinkBuilder interface and its implementations, such as WebMvcLinkBuilder. These builders allow you to create Link instances with specific relation names.
Consider a scenario where a user can have associated orders. Instead of just a generic self link, you might want to explicitly provide a link to the user’s orders.
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@GetMapping("/users/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
User user = new User(id, "john.doe", "john.doe@example.com");
Link selfLink = linkTo(methodOn(UserController.class).getUser(id)).withSelfRel();
Link ordersLink = linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders");
return EntityModel.of(user).add(selfLink, ordersLink);
}
}
@RestController
class OrderController {
@GetMapping("/users/{userId}/orders")
public String getUserOrders(@PathVariable Long userId) {
return "List of orders for user " + userId;
}
}
class User {
private Long id;
private String username;
private String email;
// Constructors, getters, setters
public User(Long id, String username, String email) {
this.id = id;
this.username = username;
this.email = email;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
}
In this example, we explicitly create a Link to the getUserOrders method in OrderController and assign it the relation name “orders”. The resulting JSON would include:
{
"id": 123,
"username": "john.doe",
"email": "john.doe@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/users/123"
},
"orders": {
"href": "http://localhost:8080/users/123/orders"
}
}
}
This demonstrates how you can use withRel() to customize the relation names of your links, providing more semantic meaning to the client.
Method 2: Customizing Link Representation with LinkRelationProvider
For more advanced customization of how link relations are generated from your domain objects, you can implement a LinkRelationProvider. This interface is responsible for determining the relation name for a given entity. By default, Spring HATEOAS uses a DefaultLinkRelationProvider which pluralizes simple class names to generate collection relations (e.g., User becomes users).
To create a custom LinkRelationProvider, you need to implement the interface and register it as a Spring bean.
import org.springframework.hateoas.LinkRelation;
import org.springframework.hateoas.server.LinkRelationProvider;
import org.springframework.stereotype.Component;
@Component
public class CustomLinkRelationProvider implements LinkRelationProvider {
@Override
public LinkRelation getItemResourceRelFor(Class<?> type) {
// Customize the relation for single items
if (type.equals(User.class)) {
return LinkRelation.of("user-details");
}
return LinkRelation.of(type.getSimpleName().toLowerCase());
}
@Override
public LinkRelation getCollectionResourceRelFor(Class<?> type) {
// Customize the relation for collections
if (type.equals(User.class)) {
return LinkRelation.of("all-users");
}
return LinkRelation.of(type.getSimpleName().toLowerCase() + "s");
}
@Override
public boolean supports(Class<?> delimiter) {
return true; // Support all types
}
}
With this custom provider, when you use CollectionModel<User> or EntityModel<User>, the generated link relations will be “all-users” and “user-details” respectively, instead of the default “users” and “user”.
To see this in action:
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserCollectionController {
@GetMapping("/users")
public CollectionModel<EntityModel<User>> getAllUsers() {
List<User> users = Stream.of(
new User(1L, "john.doe", "john.doe@example.com"),
new User(2L, "jane.doe", "jane.doe@example.com")
).collect(Collectors.toList());
List<EntityModel<User>> userModels = users.stream()
.map(user -> EntityModel.of(user, linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()))
.collect(Collectors.toList());
Link selfLink = linkTo(methodOn(UserCollectionController.class).getAllUsers()).withSelfRel();
return CollectionModel.of(userModels, selfLink);
}
}
The resulting JSON would now have:
{
"_embedded": {
"all-users": [
{
"id": 1,
"username": "john.doe",
"email": "john.doe@example.com",
"_links": {
"user-details": {
"href": "http://localhost:8080/users/1"
}
}
},
{
"id": 2,
"username": "jane.doe",
"email": "jane.doe@example.com",
"_links": {
"user-details": {
"href": "http://localhost:8080/users/2"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/users"
}
}
}
This approach provides a global way to customize how relation names are derived based on your entity types.
Method 3: Using RepresentationModelProcessor for Post-Processing
Sometimes, you might need more fine-grained control over the _links structure after it has been initially generated. This is where RepresentationModelProcessor comes into play. You can implement this interface for a specific RepresentationModel type, and Spring HATEOAS will invoke your processor after the model has been created, allowing you to add, modify, or remove links.
Let’s say you want to group all order-related links under a top-level “orders” key in the _links section of a user resource.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.RepresentationModelProcessor;
import org.springframework.stereotype.Component;
@Component
public class UserRepresentationModelProcessor implements RepresentationModelProcessor<EntityModel<User>> {
@Override
public EntityModel<User> process(EntityModel<User> model) {
List<Link> ordersLinks = new ArrayList<>();
Link selfLink = model.getLink("self").orElse(null);
Link ordersLink = linkTo(methodOn(OrderController.class).getUserOrders(model.getContent().getId())).withRel("user-orders");
ordersLinks.add(ordersLink);
Map<String, Object> customLinks = new HashMap<>();
customLinks.put("user-details", selfLink.get());
customLinks.put("orders", ordersLinks.stream().map(Link::getHref).collect(java.util.stream.Collectors.toList()));
model.removeLinks(); // Remove existing links
model.add(Link.of(customLinks, "_links")); // Add the custom structure
return model;
}
}
In this processor, we retrieve the existing “self” link, create an “orders” link, and then restructure the _links section to group the “user-orders” link under a top-level “orders” key, alongside the “user-details” link. The resulting JSON would look something like:
{
"id": 123,
"username": "john.doe",
"email": "john.doe@example.com",
"_links": {
"user-details": {
"href": "http://localhost:8080/users/123"
},
"orders": [
"http://localhost:8080/users/123/orders"
]
}
}
RepresentationModelProcessor offers significant flexibility for manipulating the final _links structure based on the specific resource being represented.
Method 4: Customizing the ObjectMapper for Advanced Formatting
For the most intricate customization, you can delve into the underlying JSON serialization process by configuring the ObjectMapper used by Spring HATEOAS. This allows you to alter the fundamental structure of the _links section.
For instance, you might want to flatten the _links and embed the link URLs directly within the resource representation. This approach should be used cautiously as it deviates from the standard HATEOAS convention, but it might be suitable for specific client needs or simplified scenarios.
You can customize the ObjectMapper by creating a @Configuration class and defining a Jackson2HalModule bean.
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.mediatype.hal.Jackson2HalModule;
import org.springframework.hateoas.mediatype.hal.forms.HalFormsConfiguration;
@Configuration
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL_FORMS)
public class HalConfig {
@Bean
public ObjectMapper halObjectMapper(HalFormsConfiguration halFormsConfiguration) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new Jackson2HalModule());
// Add custom serializers/deserializers here if needed
return mapper;
}
}
Within this halObjectMapper bean, you could potentially register custom serializers to handle the Link objects in a way that flattens the _links structure. This would involve creating a custom JsonSerializer for the Link class or for the RepresentationModel itself.
Caution: Implementing custom serializers can be complex and might require a deep understanding of Jackson’s serialization process and Spring HATEOAS’s internal representation of links. This method offers the most control but also the highest level of complexity and potential for breaking the expected HATEOAS structure.
For example, a highly simplified (and potentially not fully functional without significant custom serializer implementation) conceptual approach to flattening might involve a custom serializer that intercepts the Link objects and adds their href values directly as fields in the parent JSON object instead of nesting them under _links.
// Conceptual (requires full JsonSerializer implementation)
/*
public class FlatteningLinkSerializer extends JsonSerializer<Link> {
@Override
public void serialize(Link value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStringField(value.getRel().value() + "_href", value.getHref());
}
}
*/
Method 5: Utilizing Profiles and Media Types for Contextual Links (Continued)
Spring HATEOAS allows you to associate links with specific media types or profiles, enabling you to serve different link structures or include additional information based on the client’s request. This is particularly useful for:
- Versioning: Providing different link sets for different API versions.
- Client Capabilities: Offering more detailed links to clients that explicitly request them via custom media types.
- Domain-Specific Extensions: Including links relevant to a particular domain or application feature when a specific profile is requested.
Associating Links with Media Types
You can associate a Link with a specific MediaType when creating it. This allows you to offer different sets of links based on the Accept header sent by the client.
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/users")
public class UserMediaTypeController {
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public EntityModel<User> getUserJson(@PathVariable Long id) {
User user = new User(id, "john.doe", "john.doe@example.com");
Link selfLink = linkTo(methodOn(UserMediaTypeController.class).getUserJson(id)).withSelfRel();
Link ordersLink = linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders");
return EntityModel.of(user).add(selfLink, ordersLink);
}
@GetMapping(value = "/{id}", produces = "application/vnd.example.user+json")
public EntityModel<User> getUserCustomMediaType(@PathVariable Long id) {
User user = new User(id, "john.doe", "john.doe@example.com");
Link selfLink = linkTo(methodOn(UserMediaTypeController.class).getUserCustomMediaType(id)).withSelfRel();
Link ordersLink = linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("user-orders");
Link profileLink = linkTo(methodOn(UserProfileController.class).getUserProfile(id)).withRel("profile");
return EntityModel.of(user).add(selfLink, ordersLink, profileLink);
}
}
@RestController
@RequestMapping("/profiles")
class UserProfileController {
@GetMapping("/{userId}")
public String getUserProfile(@PathVariable Long userId) {
return "Profile information for user " + userId;
}
}
In this example, if a client requests application/json, they will receive a standard set of links (self, orders). However, if they request application/vnd.example.user+json, they will receive an additional link with the relation “profile”. Spring’s content negotiation mechanism handles selecting the appropriate controller method based on the Accept header.
While this approach allows you to tailor the entire resource representation based on the media type, it implicitly affects the _links section as part of that representation.
Utilizing Profiles with Affordance
Spring HATEOAS also provides the concept of Affordance, which can be associated with specific profiles. Affordances describe the possible state transitions or actions that can be performed on a resource, and they can include links that are only relevant under certain profiles.
To leverage profiles with Affordance, you typically work with ResourceSupport (the superclass of RepresentationModel) and define affordances that are active for specific profiles.
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import java.util.List;
import org.springframework.hateoas.Affordance;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.mediatype.hal.HalModelBuilder;
import org.springframework.hateoas.server.core.AffordanceBuilder;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/products")
public class ProductController {
@GetMapping("/{id}")
public EntityModel<Product> getProduct(@PathVariable Long id) {
Product product = new Product(id, "Awesome Gadget", 29.99);
Link selfLink = linkTo(methodOn(ProductController.class).getProduct(id)).withSelfRel();
Affordance updateAffordance = AffordanceBuilder.afford(methodOn(ProductController.class).updateProduct(id, null))
.withInput(Product.class)
.withProfile("application/vnd.example.product.update+json");
return EntityModel.of(product)
.add(selfLink.withAffordance(updateAffordance));
}
// Dummy update method
public Product updateProduct(@PathVariable Long id, Product product) {
// Implementation to update the product
return product;
}
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CollectionModel<EntityModel<Product>> getAllProducts() {
List<Product> products = List.of(
new Product(1L, "Awesome Gadget", 29.99),
new Product(2L, "Another Widget", 19.99)
);
List<EntityModel<Product>> productModels = products.stream()
.map(product -> EntityModel.of(product, linkTo(methodOn(ProductController.class).getProduct(product.getId())).withSelfRel()))
.toList();
Link selfLink = linkTo(methodOn(ProductController.class).getAllProducts()).withSelfRel();
return CollectionModel.of(productModels, selfLink);
}
}
class Product {
private Long id;
private String name;
private double price;
// Constructors, getters, setters
public Product(Long id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
In this example, the updateAffordance is associated with the profile application/vnd.example.product.update+json. When a client requests the product resource without specifying this profile, the _links section will only contain the self link. However, if the client sends an Accept header that includes this profile, the _links section (or potentially a separate _affordances section, depending on the Spring HATEOAS version and configuration) will include information about the updateProduct affordance, potentially including a link template.
The exact JSON structure for affordances can vary based on the media type configuration (e.g., HAL-FORMS). For HAL-FORMS, the affordance might be rendered within the _links section with additional properties describing the form and input fields.
To enable HAL-FORMS support (which enhances profile-based affordances), you would typically include the following dependency in your pom.xml:
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>YOUR_SPRING_HATEOAS_VERSION</version>
<classifier>hal-forms</classifier>
</dependency>
And configure it in your Spring configuration:
import org.springframework.context.annotation.Configuration;
import org.springframework.hateoas.config.EnableHypermediaSupport;
@Configuration
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL_FORMS)
public class HateoasConfig {
// ... other configurations
}
With HAL-FORMS enabled, a request for the product with the application/vnd.example.product.update+json profile might result in a _links section that includes an affordance describing the update operation:
{
"id": 1,
"name": "Awesome Gadget",
"price": 29.99,
"_links": {
"self": {
"href": "http://localhost:8080/products/1"
},
"updateProduct": {
"href": "http://localhost:8080/products/1",
"templated": true,
"profile": "application/vnd.example.product.update+json",
"method": "PUT",
"fields": [
{
"name": "name",
"type": "text"
},
{
"name": "price",
"type": "number"
}
]
}
}
}
This demonstrates how profiles can be used to conditionally include more detailed information about the available actions on a resource within the _links structure.
Considerations for Media Types and Profiles
- Content Negotiation: Ensure your controllers are set up to handle different
Acceptheaders correctly using theproducesattribute of@RequestMappingor@GetMapping. - Profile Definition: Clearly define your custom media types and profiles and ensure clients understand their meaning and the additional information they provide.
- HAL-FORMS Integration: If you intend to use profiles extensively for describing actions, consider integrating HAL-FORMS for a standardized way of representing affordances.
- Complexity: Overusing custom media types and profiles can increase the complexity of your API. Ensure the benefits of providing contextual links outweigh the added complexity for both the server and the clients.
By strategically using media types and profiles, you can create more context-aware and adaptable hypermedia APIs that cater to the specific needs and capabilities of different clients. This approach allows you to evolve your API and provide richer interactions without breaking existing clients that might not support the newer media types or profiles.
Conclusion
Customizing the _links structure in Spring HATEOAS empowers you to go beyond default representations and tailor your API’s hypermedia to specific needs. Whether it’s providing more semantic relation names with LinkBuilder, establishing consistent naming conventions through LinkRelationProvider, performing intricate post-processing with RepresentationModelProcessor, undertaking advanced serialization adjustments with a custom ObjectMapper, or offering contextual links based on media types and profiles, Spring HATEOAS provides a flexible toolkit. The key is to choose the approach that best balances clarity, consistency, and the specific requirements of your API consumers, ultimately leading to more discoverable and evolvable RESTful services.




