#837 - Add section on affordances.

This commit is contained in:
Greg Turnquist
2019-03-01 13:02:23 -06:00
parent fc49f3ead4
commit 2ec1bb73aa
3 changed files with 388 additions and 3 deletions

40
pom.xml
View File

@@ -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>

View File

@@ -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());
}
}
}

View File

@@ -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.