#837 - Add section on affordances.
This commit is contained in:
40
pom.xml
40
pom.xml
@@ -696,6 +696,26 @@
|
||||
</extensions>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>1.10</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-docs-source</id>
|
||||
<phase>generate-test-sources</phase>
|
||||
<goals>
|
||||
<goal>add-test-source</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sources>
|
||||
<source>src/docs/java</source>
|
||||
</sources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
@@ -706,6 +726,26 @@
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>1.10</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-docs-source</id>
|
||||
<phase>generate-test-sources</phase>
|
||||
<goals>
|
||||
<goal>add-test-source</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<sources>
|
||||
<source>src/docs/java</source>
|
||||
</sources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2019 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.hateoas;
|
||||
|
||||
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.springframework.hateoas.support.Employee;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* @author Greg Turnquist
|
||||
*/
|
||||
@RestController
|
||||
public class EmployeeController {
|
||||
|
||||
private static Map<Integer, Employee> EMPLOYEES;
|
||||
|
||||
public static void reset() {
|
||||
|
||||
EMPLOYEES = new TreeMap<>();
|
||||
|
||||
EMPLOYEES.put(0, new Employee("Frodo Baggins", "ring bearer"));
|
||||
EMPLOYEES.put(1, new Employee("Bilbo Baggins", "burglar"));
|
||||
}
|
||||
|
||||
@GetMapping("/employees")
|
||||
public CollectionModel<EntityModel<Employee>> all() {
|
||||
|
||||
Class<EmployeeController> controllerClass = EmployeeController.class;
|
||||
|
||||
// Generate an "Affordance" based on this method (the "self" link)
|
||||
Link selfLink = linkTo(methodOn(controllerClass).all()).withSelfRel() // <1>
|
||||
.andAffordance(afford(methodOn(controllerClass).newEmployee(null))); // <2>
|
||||
|
||||
// Return the collection of employee resources along with the composite affordance
|
||||
return IntStream.range(0, EMPLOYEES.size()) //
|
||||
.mapToObj(this::findOne) //
|
||||
.collect(Collectors.collectingAndThen(Collectors.toList(), //
|
||||
it -> new CollectionModel<>(it, selfLink)));
|
||||
}
|
||||
|
||||
@GetMapping("/employees/search")
|
||||
public CollectionModel<EntityModel<Employee>> search(@RequestParam(value = "name", required = false) String name,
|
||||
@RequestParam(value = "role", required = false) String role) {
|
||||
|
||||
// Create a list of Resource<Employee>'s to return
|
||||
List<EntityModel<Employee>> employees = new ArrayList<>();
|
||||
|
||||
// Fetch each Resource<Employee> using the controller's findOne method.
|
||||
for (int i = 0; i < EMPLOYEES.size(); i++) {
|
||||
|
||||
EntityModel<Employee> employeeResource = findOne(i);
|
||||
|
||||
boolean nameMatches = Optional.ofNullable(name) //
|
||||
.map(s -> employeeResource.getContent().getName().contains(s)) //
|
||||
.orElse(true);
|
||||
|
||||
boolean roleMatches = Optional.ofNullable(role) //
|
||||
.map(s -> employeeResource.getContent().getRole().contains(s)) //
|
||||
.orElse(true);
|
||||
|
||||
if (nameMatches && roleMatches) {
|
||||
employees.add(employeeResource);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an "Affordance" based on this method (the "self" link)
|
||||
Link selfLink = linkTo(methodOn(EmployeeController.class).all()) //
|
||||
.withSelfRel() //
|
||||
.andAffordance(afford(methodOn(EmployeeController.class).newEmployee(null))) //
|
||||
.andAffordance(afford(methodOn(EmployeeController.class).search(null, null)));
|
||||
|
||||
// Return the collection of employee resources along with the composite affordance
|
||||
return new CollectionModel<>(employees, selfLink);
|
||||
}
|
||||
|
||||
// tag::get[]
|
||||
@GetMapping("/employees/{id}")
|
||||
public EntityModel<Employee> findOne(@PathVariable Integer id) {
|
||||
|
||||
Class<EmployeeController> controllerClass = EmployeeController.class;
|
||||
|
||||
// Start the affordance with the "self" link, i.e. this method.
|
||||
Link findOneLink = linkTo(methodOn(controllerClass).findOne(id)).withSelfRel(); // <1>
|
||||
|
||||
// Return the affordance + a link back to the entire collection resource.
|
||||
return new EntityModel<>(EMPLOYEES.get(id), //
|
||||
findOneLink //
|
||||
.andAffordance(afford(methodOn(controllerClass) //
|
||||
.updateEmployee(null, id))) // <2>
|
||||
.andAffordance(afford(methodOn(controllerClass) //
|
||||
.partiallyUpdateEmployee(null, id)))); // <3>
|
||||
}
|
||||
// end::get[]
|
||||
|
||||
@PostMapping("/employees")
|
||||
public ResponseEntity<?> newEmployee(@RequestBody EntityModel<Employee> employee) {
|
||||
|
||||
int newEmployeeId = EMPLOYEES.size();
|
||||
EMPLOYEES.put(newEmployeeId, employee.getContent());
|
||||
|
||||
Link link = linkTo(methodOn(getClass()).findOne(newEmployeeId)).withSelfRel().expand();
|
||||
|
||||
return ResponseEntity.created(URI.create(link.getHref())).build();
|
||||
}
|
||||
// end::new[]
|
||||
|
||||
// tag::put[]
|
||||
@PutMapping("/employees/{id}")
|
||||
public ResponseEntity<?> updateEmployee( //
|
||||
@RequestBody EntityModel<Employee> employee, @PathVariable Integer id)
|
||||
// end::put[]
|
||||
{
|
||||
|
||||
EMPLOYEES.put(id, employee.getContent());
|
||||
|
||||
Link link = linkTo(methodOn(getClass()).findOne(id)).withSelfRel().expand();
|
||||
|
||||
return ResponseEntity.noContent() //
|
||||
.location(URI.create(link.getHref())) //
|
||||
.build();
|
||||
}
|
||||
|
||||
// tag::patch[]
|
||||
@PatchMapping("/employees/{id}")
|
||||
public ResponseEntity<?> partiallyUpdateEmployee( //
|
||||
@RequestBody EntityModel<Employee> employee, @PathVariable Integer id)
|
||||
// end::patch[]
|
||||
{
|
||||
|
||||
Employee oldEmployee = EMPLOYEES.get(id);
|
||||
Employee newEmployee = oldEmployee;
|
||||
|
||||
if (employee.getContent().getName() != null) {
|
||||
newEmployee = newEmployee.withName(employee.getContent().getName());
|
||||
}
|
||||
|
||||
if (employee.getContent().getRole() != null) {
|
||||
newEmployee = newEmployee.withRole(employee.getContent().getRole());
|
||||
}
|
||||
|
||||
EMPLOYEES.put(id, newEmployee);
|
||||
|
||||
try {
|
||||
return ResponseEntity //
|
||||
.noContent() //
|
||||
.location( //
|
||||
new URI(findOne(id) //
|
||||
.getLink(IanaLinkRelations.SELF) //
|
||||
.map(link -> link.expand().getHref()) //
|
||||
.orElse("") //
|
||||
) //
|
||||
).build();
|
||||
} catch (URISyntaxException e) {
|
||||
return ResponseEntity.badRequest().body(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
:code: ../../..
|
||||
|
||||
|
||||
[[fundamentals]]
|
||||
= Fundamentals
|
||||
|
||||
@@ -122,16 +125,16 @@ For more information on this, have a look at <<mediatypes>>
|
||||
.The `RepresentationModel` class hierarchy
|
||||
====
|
||||
[plantuml, diagram-classes, svg]
|
||||
----
|
||||
....
|
||||
class RepresentationModel
|
||||
class EntityModel
|
||||
class CollectionModel
|
||||
class CollectionModel
|
||||
class PagedModel
|
||||
|
||||
EntityModel -|> RepresentationModel
|
||||
CollectionModel -|> RepresentationModel
|
||||
PagedModel -|> CollectionModel
|
||||
----
|
||||
....
|
||||
====
|
||||
|
||||
The default way to work with a `RepresentationModel` is to create a subclass of it to contain all the properties the representation is supposed to contain, create instances of that class, populate the properties and enrich it with links.
|
||||
@@ -207,3 +210,156 @@ Collection<Person> people = Collections.singleton(new Person("Dave", "Matthews")
|
||||
CollectionModel<Person> model = new CollectionModel<>(people);
|
||||
----
|
||||
====
|
||||
|
||||
[[fundamentals.affordances]]
|
||||
== Affordances
|
||||
|
||||
[quote, James J. Gibson, The Ecological Approach to Visual Perception (page 126)]
|
||||
____
|
||||
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.
|
||||
____
|
||||
|
||||
REST-based resources provide not just data but controls. The last ingredient to form a flexible service are detailed *affordances*
|
||||
on how to use the various controls.
|
||||
|
||||
Because affordances are associated with links, Spring HATEOAS provides an API to attach as many related methods as needed to a link.
|
||||
The following code shows how to take a *self* link and associate two more affordances:
|
||||
|
||||
.Connecting affordances to `GET /employees/{id}`
|
||||
====
|
||||
[source,java,indent=0]
|
||||
----
|
||||
include::{code}/src/docs/java/org/springframework/hateoas/EmployeeController.java[tag=get]
|
||||
----
|
||||
<1> Create the *self* link.
|
||||
<2> Associate the `updateEmployee` method with the `self` link.
|
||||
<3> Associate the `partiallyUpdateEmployee` method with the `self` link.
|
||||
|
||||
Using `.andAffordance(afford(...))`, you can use the controller's methods to connect a `PUT` and a `PATCH` operation to a `GET` operation.
|
||||
====
|
||||
|
||||
Imagine that the related methods *afforded* above looking like this:
|
||||
|
||||
.`updateEmpoyee` method that responds to `PUT /employees/{id}`
|
||||
====
|
||||
[source,java,indent=0]
|
||||
----
|
||||
include::{code}/src/docs/java/org/springframework/hateoas/EmployeeController.java[tag=put]
|
||||
----
|
||||
====
|
||||
|
||||
.`partiallyUpdateEmployee` method that responds to `PATCH /employees/{id}`
|
||||
====
|
||||
[source,java,indent=0]
|
||||
----
|
||||
include::{code}/src/docs/java/org/springframework/hateoas/EmployeeController.java[tag=patch]
|
||||
----
|
||||
====
|
||||
|
||||
There are many media types that support rendering affordances. Unfortunately, HAL isn't one of them.
|
||||
|
||||
A HAL document for `GET /employees/{id}` would look like this:
|
||||
|
||||
.HAL document with no affordances
|
||||
====
|
||||
[source, json]
|
||||
----
|
||||
{
|
||||
"firstname" : "Frodo",
|
||||
"lastname" : "Baggins",
|
||||
"role" : "ring bearer",
|
||||
"_links" : {
|
||||
"self" : {
|
||||
"href" : "http://localhost:8080/employees/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
----
|
||||
====
|
||||
|
||||
HAL supports providing links, but nothing else. While powerful, it doesn't let you show clients what inputs are required
|
||||
by its various operations. Nor does it show _what_ HTTP methods are supported.
|
||||
|
||||
However, https://rwcbook.github.io/hal-forms/[HAL-FORMS] (`application/prs.hal-forms+json`), is a backwards compatible
|
||||
extension of HAL s that adds `_templates`. This affordance-aware media type can fill in what's missing.
|
||||
|
||||
The same resource above will render the following HAL-FORMS document:
|
||||
|
||||
.HAL-FORMS document with affordances
|
||||
====
|
||||
[source,json]
|
||||
----
|
||||
{
|
||||
"firstName" : "Frodo",
|
||||
"lastName" : "Baggins",
|
||||
"role" : "ring bearer",
|
||||
"_links" : {
|
||||
"self" : {
|
||||
"href" : "http://localhost:8080/employees/1"
|
||||
}
|
||||
},
|
||||
"_templates" : { // <1>
|
||||
"default" : {
|
||||
"title" : null,
|
||||
"method" : "put", // <2>
|
||||
"contentType" : "",
|
||||
"properties" : [ { // <3>
|
||||
"name" : "firstName",
|
||||
"required" : true // <4>
|
||||
}, {
|
||||
"name" : "lastName",
|
||||
"required" : true
|
||||
}, {
|
||||
"name" : "role",
|
||||
"required" : true
|
||||
} ]
|
||||
},
|
||||
"partiallyUpdateEmployee" : { // <5>
|
||||
"title" : null,
|
||||
"method" : "patch", // <6>
|
||||
"contentType" : "",
|
||||
"properties" : [ {
|
||||
"name" : "firstName",
|
||||
"required" : false // <7>
|
||||
}, {
|
||||
"name" : "lastName",
|
||||
"required" : false
|
||||
}, {
|
||||
"name" : "role",
|
||||
"required" : false
|
||||
} ]
|
||||
}
|
||||
}
|
||||
}
|
||||
----
|
||||
<1> The `_templates` attribute provided by HAL-FORMS with affordance-based information.
|
||||
<2> The `updateEmployee` method's `@PutMapping` annotation is translated to `put`.
|
||||
<3> The method's `@RequestBody` input type is used to find domain `properties`.
|
||||
<4> For `POST` and `PUT`, all attributes are `required`.
|
||||
<5> The second affordance is named after the `partiallyUpdateEmployee` method.
|
||||
<6> `@PatchMapping` is translated into `patch`.
|
||||
<7> For `PATCH`, attributes are _not_ `required`.
|
||||
====
|
||||
|
||||
This rich document, consumable by any HAL-FORMS aware client (as well as a HAL client configured to ignore unknown
|
||||
attributes), includes enough extra details for full interaction with the resource.
|
||||
|
||||
In fact, this type of document makes it easy to write custom client-side code to generate an HTML form:
|
||||
|
||||
[source,html]
|
||||
----
|
||||
<form method="put" action="http://localhost:8080/employees/1">
|
||||
<input type="text" id="firstName" name="firstName"/>
|
||||
<input type="text" id="lastName" name="lastName" />
|
||||
<input type="text" id="role" name="role" />
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
----
|
||||
|
||||
Letting hypermedia drive web forms for users reduces the need for the client to know about the domain.
|
||||
|
||||
By trading in domain knowledge and instead adding protocol support for HAL-FORMS, clients can become flexible and receptive
|
||||
to server-side changes. No need to update your client every time a domain change is made on the server.
|
||||
|
||||
IMPORTANT: HAL-FORMS only supports affordances against the `self` link, but other affordance-aware media types may not
|
||||
have the same restriction. In general, don't define affordances based on one particular media type.
|
||||
Reference in New Issue
Block a user