493 lines
18 KiB
Plaintext
493 lines
18 KiB
Plaintext
= Spring HATEOAS - Affordances Example
|
|
|
|
This guide shows the opportunities to build hypermedia that http://amundsen.com/blog/archives/1109[affords] operations
|
|
using https://rwcbook.github.io/hal-forms/[HAL-FORMS].
|
|
|
|
Before proceeding, have you read these yet?
|
|
|
|
. link:../basics[Spring HATEOAS - Basic Example]
|
|
. link:../api-evolution[Spring HATEOAS - API Evolution Example]
|
|
. link:../hypermedia[Spring HATEOAS - Hypermedia Example]
|
|
|
|
You may wish to read them first before reading this one.
|
|
|
|
NOTE: This example uses https://projectlombok.org[Project Lombok] to reduce writing Java code.
|
|
|
|
== Building "affordances"
|
|
|
|
For starters, what is an *affordance*? Doing a little archeology, Mike Admundsen, a REST advocate, has
|
|
http://amundsen.com/blog/archives/1109[an article detailing the word's origins], going back at least to 1986:
|
|
|
|
[verse, The Ecological Approach to Visual Perception (Gibson)]
|
|
The affordances of the environment are what it offers ... what it provides or furnishes, either for good or ill.
|
|
The verb 'to afford' is found in the dictionary, but the noun 'affordance' is not. I have made it up (page 126).
|
|
|
|
It then appeared in a psychology paper in 1988:
|
|
|
|
[verse, The Design|Psychology of Everyday Things (Norman)]
|
|
...the term affordance refers to the perceived and actual properties of the thing, primarily those fundamental properties
|
|
that determine just how the thing could possibly be used. (pg 9)
|
|
|
|
Finally, it can be found in none other than one of Roy Fielding's presentations on hypermedia in 2008:
|
|
|
|
[verse, Slide presention on REST (Fielding)]
|
|
When I say Hypertext, I mean the simultaneous presentation of information and controls such that the information becomes
|
|
the affordance through which the user obtains choices and selects actions (slide #50).
|
|
|
|
In all these situations, "affordance" refers to the available actions provided by the surrounding environment. In the
|
|
context of REST, these are actions detailed by the hypermedia. With a HAL document, you are provided very simple affordances.
|
|
The links are shown but nothing else about them. What you can do with the links and what it takes to interact with them
|
|
is not detailed.
|
|
|
|
You can use content negotation to discover what HTTP verbs are supported. And you can take a shot at supplying properties
|
|
based on existing data records. But the beauty of REST is that by not having a single megaspec, you can adjust and adapt
|
|
by adopting new media types. HAL has been well received, but perhaps there is something better?
|
|
|
|
HAL-FORMS, an extension of HAL, attempts to bridge this gap. It supports HAL's lightweight of concept of data and links,
|
|
but introduces another element, *_templates*. *_templates* make it possible to show all the operations possible as well as
|
|
the attributes needed for each operation.
|
|
|
|
The following bits of code show how to use Spring HATEOAS's Affordances API to produce HAL-FORMS documents.
|
|
|
|
== Defining Your Domain
|
|
|
|
This example takes off where Basics and API Evolution end: an employee payroll system.
|
|
|
|
Here is the basic definition:
|
|
|
|
[source,java]
|
|
----
|
|
@Data
|
|
@Entity
|
|
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
|
@AllArgsConstructor
|
|
class Employee {
|
|
|
|
@Id @GeneratedValue
|
|
private Long id;
|
|
private String firstName;
|
|
private String lastName;
|
|
private String role;
|
|
|
|
/**
|
|
* Useful constructor when id is not yet known.
|
|
*/
|
|
Employee(String firstName, String lastName, String role) {
|
|
|
|
this.firstName = firstName;
|
|
this.lastName = lastName;
|
|
this.role = role;
|
|
}
|
|
}
|
|
----
|
|
|
|
This domain object should look quite familiar by now.
|
|
|
|
A corresponding Spring Data JPA repository is defined:
|
|
|
|
[source,java]
|
|
----
|
|
interface EmployeeRepository extends CrudRepository<Employee, Long> {
|
|
}
|
|
----
|
|
|
|
This barebones repository provides standard Spring Data CRUD operations.
|
|
|
|
Next, you define a REST controller:
|
|
|
|
[source,java]
|
|
----
|
|
@RestController
|
|
class EmployeeController {
|
|
|
|
private final EmployeeRepository repository;
|
|
|
|
EmployeeController(EmployeeRepository repository) {
|
|
this.repository = repository;
|
|
}
|
|
|
|
...
|
|
}
|
|
----
|
|
|
|
This controller has these characteristics:
|
|
|
|
* `@RestController` makes the entire controller render responses as direct JSON and not rendered templates.
|
|
* Injects `EmployeeRepository` through constructor injection.
|
|
|
|
Next, you need to define Spring MVC endpoints for the aggregate collection:
|
|
|
|
[source,java]
|
|
----
|
|
@RestController
|
|
class EmployeeController {
|
|
|
|
...
|
|
|
|
@GetMapping("/employees")
|
|
ResponseEntity<CollectionModel<EntityModel<Employee>>> findAll() {
|
|
|
|
List<EntityModel<Employee>> employeeResources = StreamSupport.stream(repository.findAll().spliterator(), false)
|
|
.map(employee -> EntityModel.of(employee,
|
|
linkTo(methodOn(EmployeeController.class).findOne(employee.getId())).withSelfRel()
|
|
.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, employee.getId())))
|
|
.andAffordance(afford(methodOn(EmployeeController.class).deleteEmployee(employee.getId()))),
|
|
linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees")))
|
|
.collect(Collectors.toList());
|
|
|
|
return ResponseEntity.ok(CollectionModel.of( //
|
|
employeeResources, //
|
|
linkTo(methodOn(EmployeeController.class).findAll()).withSelfRel()
|
|
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null)))));
|
|
}
|
|
|
|
@PostMapping("/employees")
|
|
ResponseEntity<?> newEmployee(@RequestBody Employee employee) {
|
|
|
|
Employee savedEmployee = repository.save(employee);
|
|
|
|
return EntityModel.of(savedEmployee,
|
|
linkTo(methodOn(EmployeeController.class).findOne(savedEmployee.getId())).withSelfRel()
|
|
.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, savedEmployee.getId())))
|
|
.andAffordance(afford(methodOn(EmployeeController.class).deleteEmployee(savedEmployee.getId()))),
|
|
linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees")).getLink(IanaLinkRelations.SELF)
|
|
.map(Link::getHref) //
|
|
.map(href -> {
|
|
try {
|
|
return new URI(href);
|
|
} catch (URISyntaxException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}) //
|
|
.map(uri -> ResponseEntity.noContent().location(uri).build())
|
|
.orElse(ResponseEntity.badRequest().body("Unable to create " + employee));
|
|
}
|
|
|
|
...
|
|
}
|
|
----
|
|
|
|
Look at these controller details:
|
|
|
|
* A *GET* call for the aggregate collection is laid out. It uses the repository's `findAll()` method and transforms it
|
|
into a `CollectionModel<EntityModel<Employee>>`.
|
|
* A *POST* call for creating new employees is also defined, on the same URI. `@RequestBody` tells Spring MVC to deserialize
|
|
the request body into an `Employee` object, which is then sent through the repository's `save()` operation. From there,
|
|
it's wrapped as a `Resource` with links added to itself and to the aggregate root. Finally a `Location` response header
|
|
* Buried in both endpoints is the new `.andAffordance()` API. Instead of `linkTo()`, you instead use the `afford()` API
|
|
to show related information.
|
|
|
|
Affordances is about chaining related links together to support richer media types. In this case, Spring HATEOAS has HAL-FORMS support. This means
|
|
you can connect the *GET* link to its related *POST* link using the `andAffordance(afford(methodOn(...))`. A given link can
|
|
also connect to multiple affordances. That's why this example also shows linking to the `deleteEmployee` endpoint as well.
|
|
|
|
The `methodOn()` API works just like the other examples show. But the `afford()` operation, based on web-specific technology (in this
|
|
case Spring MVC), is able to look up details about the endpoint and flesh out the *_templates* section of a HAL-FORMS document.
|
|
|
|
The premise is that the *POST* endpoint and the *DELETE* endpoint are related to the *GET* endpoint. In other words, the
|
|
URI at `/employees` services a *GET* call while _also affording_ a *POST* call and a *DELETE* call. And with the Affordances
|
|
API, it captures the important details found in the related Spring MVC endpoint. When fetching a list of employees, there
|
|
are two sets of links, the links for each individual entry along with the aggregate links.
|
|
|
|
In the aggregate links, you can see a *self* link to the collection, but connected, i.e. afforded to the `newEmployee()`
|
|
endpoint. For each individual employee in the collection, there is a *self* link to itself along with an affordance to
|
|
the `updateEmployee()` endpoint, that you'll define next.
|
|
|
|
Check it out below:
|
|
|
|
[source,java]
|
|
----
|
|
@RestController
|
|
class EmployeeController {
|
|
|
|
...
|
|
|
|
@GetMapping("/employees/{id}")
|
|
ResponseEntity<EntityModel<Employee>> findOne(@PathVariable long id) {
|
|
|
|
return repository.findById(id)
|
|
.map(employee -> EntityModel.of(employee,
|
|
linkTo(methodOn(EmployeeController.class).findOne(employee.getId())).withSelfRel()
|
|
.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, employee.getId())))
|
|
.andAffordance(afford(methodOn(EmployeeController.class).deleteEmployee(employee.getId()))),
|
|
linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees")))
|
|
.map(ResponseEntity::ok) //
|
|
.orElse(ResponseEntity.notFound().build());
|
|
}
|
|
|
|
@PutMapping("/employees/{id}")
|
|
ResponseEntity<?> updateEmployee(@RequestBody Employee employee, @PathVariable long id) {
|
|
|
|
Employee employeeToUpdate = employee;
|
|
employeeToUpdate.setId(id);
|
|
|
|
Employee updatedEmployee = repository.save(employeeToUpdate);
|
|
|
|
return EntityModel.of(updatedEmployee,
|
|
linkTo(methodOn(EmployeeController.class).findOne(updatedEmployee.getId())).withSelfRel()
|
|
.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, updatedEmployee.getId())))
|
|
.andAffordance(afford(methodOn(EmployeeController.class).deleteEmployee(updatedEmployee.getId()))),
|
|
linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees")).getLink(IanaLinkRelations.SELF)
|
|
.map(Link::getHref).map(href -> {
|
|
try {
|
|
return new URI(href);
|
|
} catch (URISyntaxException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
}) //
|
|
.map(uri -> ResponseEntity.noContent().location(uri).build()) //
|
|
.orElse(ResponseEntity.badRequest().body("Unable to update " + employeeToUpdate));
|
|
}
|
|
|
|
...
|
|
}
|
|
----
|
|
|
|
This will look very similar, but focused on single item employees.
|
|
|
|
NOTE: Are you sensing a repeat of code just seen? Like how the same links and affordances are defined here as was shown for
|
|
each individual part of the aggregate root? It's possible to refactor this code into a `ResourceAssembler` to define a single
|
|
location, but for simplicity, it has been left out of this example.
|
|
|
|
To round out this controller, you must also code the `deleteEmployee()` operation:
|
|
|
|
[source,java]
|
|
----
|
|
@RestController
|
|
class EmployeeController {
|
|
|
|
...
|
|
|
|
@DeleteMapping("/employees/{id}")
|
|
ResponseEntity<?> deleteEmployee(@PathVariable long id) {
|
|
|
|
repository.deleteById(id);
|
|
|
|
return ResponseEntity.noContent().build();
|
|
}
|
|
|
|
...
|
|
}
|
|
----
|
|
|
|
This operation is quite simple. It deletes based upon *id* then returns an `HTTP 204 No Content` response.
|
|
|
|
Our controller has been made more sophisticated by linking related operations together. However, to take advantage of this,
|
|
you must shift gears and use a different hypermedia-based media type. This demands on additional step. By default, Spring
|
|
Boot sets things up for HAL. To switch to HAL-FORMS, you need to create this:
|
|
|
|
[source,java]
|
|
----
|
|
@Configuration
|
|
@EnableHypermediaSupport(type = HypermediaType.HAL_FORMS)
|
|
public class HypermediaConfiguration {
|
|
|
|
}
|
|
----
|
|
|
|
There is lot packed in this tiny class:
|
|
|
|
* `@Configuration` makes this class automatically picked up by Spring Boot's component scanning.
|
|
* `@EnableHypermediaSupport(type = HypermediaType.HAL_FORMS)` activates Spring HATEOAS's hypermedia support, setting the format to HAL-FORMS.
|
|
|
|
Before launching the application, you'll want to pre-load some test data:
|
|
|
|
[source,java]
|
|
----
|
|
@Component
|
|
class DatabaseLoader {
|
|
|
|
/**
|
|
* Use Spring to inject a {@link EmployeeRepository} that can then load data. Since this will run
|
|
* only after the app is operational, the database will be up.
|
|
*
|
|
* @param repository
|
|
*/
|
|
@Bean
|
|
CommandLineRunner init(EmployeeRepository repository) {
|
|
return args -> {
|
|
repository.save(new Employee("Frodo", "Baggins", "ring bearer"));
|
|
repository.save(new Employee("Bilbo", "Baggins", "burglar"));
|
|
};
|
|
}
|
|
|
|
}
|
|
----
|
|
|
|
This little database loader will:
|
|
|
|
* Be picked up by Spring Boot's component scanning due to the `@Component` annotation.
|
|
* The `CommandLineRunner` bean is executed by Spring Boot after the entire application context is up.
|
|
* Inside that chunk of code, the injected `EmployeeRepository` is used to create a couple database entries.
|
|
|
|
NOTE: The database for this example is `H2`, an in-memory database that always starts up empty. If you switch to a persistent
|
|
store, you probably need to include the extra step to delete old data or you'll get multiple entries.
|
|
|
|
If you launch the application and `GET /employees`, you can expect the following HAL-FORMS result:
|
|
|
|
[source,javascript]
|
|
----
|
|
{
|
|
"_embedded": {
|
|
"employees": [...]
|
|
},
|
|
"_links": {
|
|
"self": {
|
|
"href": "http://localhost:8080/employees"
|
|
}
|
|
},
|
|
"_templates": {
|
|
"default": {
|
|
"title": null,
|
|
"method": "post",
|
|
"contentType": "",
|
|
"properties":[
|
|
{
|
|
"name": "firstName",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "id",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "lastName",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "role",
|
|
"required": true
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
----
|
|
|
|
This fragment of JSON can be described as follows:
|
|
|
|
* The *_embedded* chunk has been shrunk down for space reasons. It contains an array of `Employee` resources, which you'll see in more detail further down.
|
|
* The *_links* section is just like a HAL document, showing the *self* link to `localhost:8080/employees` that you declared.
|
|
* The *_templates* section is the HAL-FORMS extension that shows the *affordance* defined that pointed to the `newEmployee` method, which was mapped onto the *POST* method.
|
|
** Inside the template, the method is clearly marked *post*.
|
|
** The properties are: *firstName*, *id*, *lastName*, and *role*, and all marked as *required*.
|
|
** The other characteristics (title, contentType) are not filled out. There are more attributes, but nothing (yet) that can be gleaned from a plain old Spring MVC route.
|
|
|
|
This template data is enough information for you to generate an HTML form on a web page using a little JavaScript. Possibly one like this:
|
|
|
|
[source,html]
|
|
----
|
|
<form method="post" action="http://localhost:8080/employees">
|
|
<input type="text" id="firstName" name="firstName" placeHolder="firstName" />
|
|
<input type="text" id="id" name="id" placeHolder="id" />
|
|
<input type="text" id="lastName" name="lastName" placeHolder="lastName" />
|
|
<input type="text" id="role" name="role" placeHolder="role" />
|
|
<input type="submit" value="Submit" />
|
|
</form>
|
|
----
|
|
|
|
IMPORTANT: Spring HATEOAS doesn't provide the JavaScript to do this. This hypermedia format, though, has all the information you need to create it yourself. Or deploy somebody's
|
|
3rd party library that speaks HAL-FORMS.
|
|
|
|
Are you wondering why Spring HATEOAS doesn't simply render an HTML form straight up? There are other media types designed
|
|
for this, especially XHTML. Using the Affordances API, we plan to add support in the future, allowing you to negotiate
|
|
for the format you prefer.
|
|
|
|
Do you want HAL? HAL-FORMS? SIREN? XHTML? Whatever format you need, the relation between endpoints doesn't have to change.
|
|
Simply what you configure the server to render.
|
|
|
|
To round things out, you can also interrogate a single employee resource as shown below:
|
|
|
|
[source,javascript]
|
|
----
|
|
{
|
|
"id": 1,
|
|
"firstName": "Frodo",
|
|
"lastName": "Baggins",
|
|
"role": "ring bearer",
|
|
"_links": {
|
|
"self": {
|
|
"href": "http://localhost:8080/employees/1"
|
|
},
|
|
"employees": {
|
|
"href": "http://localhost:8080/employees"
|
|
}
|
|
},
|
|
"_templates": {
|
|
"default": {
|
|
"title": null,
|
|
"method": "put",
|
|
"contentType": "",
|
|
"properties": [
|
|
{
|
|
"name": "firstName",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "id",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "lastName",
|
|
"required": true
|
|
},
|
|
{
|
|
"name": "role",
|
|
"required": true
|
|
}
|
|
]
|
|
},
|
|
"deleteEmployee": {
|
|
"title": null,
|
|
"method": "delete",
|
|
"contentType": "",
|
|
"properties": []
|
|
}
|
|
}
|
|
}
|
|
----
|
|
|
|
* This is very similar to what you saw before, only there is no *_embedded* element. Instead, the resource's data is at the top level.
|
|
* There are two links: *self* for the canonical link to itself and *employees* to lead back to the aggregate root.
|
|
* The method of the default template is *put* instead of *post*, indicating this is for updates.
|
|
** All the properties are listed, being the same as shown at the aggregate root.
|
|
* There is a second template, *deleteEmployee* with a method of *delete*. It has no properties meaning all you need is the
|
|
URI to delete an existing employee.
|
|
|
|
This information could easily be used on your web site to generate an update form:
|
|
|
|
[source,html]
|
|
----
|
|
<form method="put" action="http://localhost:8080/employees/1">
|
|
<input type="text" id="firstName" name="firstName" placeHolder="firstName" />
|
|
<input type="text" id="id" name="id" placeHolder="id" />
|
|
<input type="text" id="lastName" name="lastName" placeHolder="lastName" />
|
|
<input type="text" id="role" name="role" placeHolder="role" />
|
|
<input type="submit" value="Submit" />
|
|
</form>
|
|
----
|
|
|
|
You could also craft another form based on the `deleteEmployee` template:
|
|
|
|
[source,html]
|
|
----
|
|
<form method="delete" action="http://localhost:8080/employees/1">
|
|
<input type="submit" value="Submit" />
|
|
</form>
|
|
----
|
|
|
|
These are just a couple ways to render forms based on the hypermedia's templates.
|
|
|
|
NOTE: `method="put"` and `method="delete"` aren't valid HTML5. Either you handle that in your code, or you
|
|
have some sort of filter like Spring MVC's `HiddenHttpMethodFilter` that lets you construct it as
|
|
`<form method="post" _method="put" ...>`, which converts a *POST* into a *PUT* before invoking the code.
|
|
|
|
IMPORTANT: With HAL-FORMS, there is no URI in the template itself. It's presumed to operate on the *self* link.
|
|
|
|
With the Affordances API, you can link related methods. And with HAL-FORMS support, it's possible to turn those
|
|
relationships into automated bits of HTML to enhance the user experience without having to inject domain knowledge into
|
|
the client layer.
|
|
|
|
And that's a key part of REST--reducing the amount of domain knowledge needed in the client. Teach your clients a little more
|
|
knowledge about the protocol, and they won't have to know as much about the domain. Instead, push the relevant
|
|
forms straight out to the end user, are more easily adapt to changes on the server! |