From 2ec1bb73aa98a862eb14e7a29df48d3e8c94079f Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Fri, 1 Mar 2019 13:02:23 -0600 Subject: [PATCH] #837 - Add section on affordances. --- pom.xml | 40 ++++ .../hateoas/EmployeeController.java | 189 ++++++++++++++++++ src/main/asciidoc/fundamentals.adoc | 162 ++++++++++++++- 3 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/docs/java/org/springframework/hateoas/EmployeeController.java diff --git a/pom.xml b/pom.xml index 1704b4ca..9aad2b67 100644 --- a/pom.xml +++ b/pom.xml @@ -696,6 +696,26 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 1.10 + + + add-docs-source + generate-test-sources + + add-test-source + + + + src/docs/java + + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -706,6 +726,26 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 1.10 + + + add-docs-source + generate-test-sources + + add-test-source + + + + src/docs/java + + + + + + org.apache.maven.plugins maven-jar-plugin diff --git a/src/docs/java/org/springframework/hateoas/EmployeeController.java b/src/docs/java/org/springframework/hateoas/EmployeeController.java new file mode 100644 index 00000000..4e762005 --- /dev/null +++ b/src/docs/java/org/springframework/hateoas/EmployeeController.java @@ -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 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> all() { + + Class 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> search(@RequestParam(value = "name", required = false) String name, + @RequestParam(value = "role", required = false) String role) { + + // Create a list of Resource's to return + List> employees = new ArrayList<>(); + + // Fetch each Resource using the controller's findOne method. + for (int i = 0; i < EMPLOYEES.size(); i++) { + + EntityModel 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 findOne(@PathVariable Integer id) { + + Class 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) { + + 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, @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, @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()); + } + } +} diff --git a/src/main/asciidoc/fundamentals.adoc b/src/main/asciidoc/fundamentals.adoc index 6a045248..ed14df50 100644 --- a/src/main/asciidoc/fundamentals.adoc +++ b/src/main/asciidoc/fundamentals.adoc @@ -1,3 +1,6 @@ +:code: ../../.. + + [[fundamentals]] = Fundamentals @@ -122,16 +125,16 @@ For more information on this, have a look at <> .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 people = Collections.singleton(new Person("Dave", "Matthews") CollectionModel 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] +---- +
+ + + + +
+---- + +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. \ No newline at end of file