Skip to content

Affordances API + HAL-Forms #581

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 58 additions & 31 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>0.24.0.BUILD-SNAPSHOT</version>
<version>0.24.0.AFFORDANCES-SNAPSHOT</version>

<name>Spring HATEOAS</name>
<url>http://github.com/SpringSource/spring-hateoas</url>
Expand Down Expand Up @@ -69,6 +70,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>4.3.5.RELEASE</spring.version>
<spring-restdocs.version>1.2.0.RELEASE</spring-restdocs.version>
<logback.version>1.1.8</logback.version>
<jacoco>0.7.8</jacoco>
<jacoco.destfile>${project.build.directory}/jacoco.exec</jacoco.destfile>
Expand All @@ -87,7 +89,7 @@
<profile>
<id>spring43-next</id>
<properties>
<spring.version>4.3.8.BUILD-SNAPSHOT</spring.version>
<spring.version>4.3.9.BUILD-SNAPSHOT</spring.version>
</properties>
<repositories>
<repository>
Expand All @@ -100,7 +102,8 @@
<profile>
<id>spring5</id>
<properties>
<spring.version>5.0.0.M5</spring.version>
<spring.version>5.0.0.RC1</spring.version>
<jackson.version>2.9.0.pr3</jackson.version>
</properties>
<repositories>
<repository>
Expand Down Expand Up @@ -193,7 +196,6 @@
<properties>
<shared.resources>${project.build.directory}/shared-resources</shared.resources>
<maven.install.skip>true</maven.install.skip>
<skipTests>true</skipTests>
<project.root>${basedir}</project.root>
</properties>

Expand All @@ -211,9 +213,8 @@

<plugins>

<!--
Unpacks the content of spring-data-build-resources into the shared resources folder.
-->
<!-- Unpacks the content of spring-data-build-resources into the shared
resources folder. -->

<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand All @@ -236,9 +237,7 @@
</configuration>
</plugin>

<!--
Configures JavaDoc generation.
-->
<!-- Configures JavaDoc generation. -->

<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand All @@ -254,6 +253,18 @@
</executions>
</plugin>

<!-- ONLY run the **DocumentationTest test cases for Spring RestDocs -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.20</version>
<configuration>
<includes>
<include>**/*DocumentationTest</include>
</includes>
</configuration>
</plugin>

<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
Expand All @@ -269,12 +280,17 @@
<artifactId>asciidoctorj-epub3</artifactId>
<version>1.5.0-alpha.6</version>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>${spring-restdocs.version}</version>
</dependency>
</dependencies>
<executions>

<execution>
<id>html</id>
<phase>generate-resources</phase>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
Expand All @@ -292,23 +308,13 @@
</configuration>
</execution>

<!--
<execution>
<id>epub</id>
<phase>generate-resources</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>epub3</backend>
<sourceHighlighter>coderay</sourceHighlighter>
</configuration>
</execution>
-->
<!-- <execution> <id>epub</id> <phase>generate-resources</phase> <goals>
<goal>process-asciidoc</goal> </goals> <configuration> <backend>epub3</backend>
<sourceHighlighter>coderay</sourceHighlighter> </configuration> </execution> -->

<execution>
<id>pdf</id>
<phase>generate-resources</phase>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
Expand Down Expand Up @@ -348,13 +354,15 @@
<configuration>
<target>
<copy todir="${project.root}/target/site/reference/html">
<fileset dir="${shared.resources}/asciidoc" erroronmissingdir="false">
<fileset dir="${shared.resources}/asciidoc"
erroronmissingdir="false">
<include name="**/*.css" />
</fileset>
<flattenmapper />
</copy>
<copy todir="${project.root}/target/site/reference/html/images">
<fileset dir="${basedir}/src/main/asciidoc" erroronmissingdir="false">
<fileset dir="${basedir}/src/main/asciidoc"
erroronmissingdir="false">
<include name="**/*.png" />
<include name="**/*.gif" />
<include name="**/*.jpg" />
Expand All @@ -373,8 +381,12 @@
<phase>process-resources</phase>
<configuration>
<target>
<copy file="${project.build.directory}/generated-docs/index.pdf" tofile="${project.root}/target/site/reference/pdf/${project.artifactId}-reference.pdf" failonerror="false" />
<copy file="${project.build.directory}/generated-docs/index.epub" tofile="${project.root}/target/site/reference/epub/${project.artifactId}-reference.epub" failonerror="false" />
<copy file="${project.build.directory}/generated-docs/index.pdf"
tofile="${project.root}/target/site/reference/pdf/${project.artifactId}-reference.pdf"
failonerror="false" />
<copy file="${project.build.directory}/generated-docs/index.epub"
tofile="${project.root}/target/site/reference/epub/${project.artifactId}-reference.epub"
failonerror="false" />
</target>
</configuration>
<goals>
Expand Down Expand Up @@ -574,6 +586,13 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<version>${spring-restdocs.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
Expand Down Expand Up @@ -602,7 +621,15 @@
<scope>test</scope>
</dependency>

<!-- Needs to be after Jadler to make sure it sees the Servlet 3.0 dependency pulled in for testing -->
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path-assert</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>

<!-- Needs to be after Jadler to make sure it sees the Servlet 3.0 dependency
pulled in for testing -->

<dependency>
<groupId>javax.servlet</groupId>
Expand Down
145 changes: 145 additions & 0 deletions src/main/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,148 @@ Link link = discoverer.findLinkWithRel("foo", content);
assertThat(link.getRel(), is("foo"));
assertThat(link.getHref(), is("/foo/bar"));
----

[[affordances]]
== Affordances

The *Affordances API* provides the means to mark up your domain objects and controllers such that you can generate
not only links, but additional queries with extra metadata.

This is done using a much richer version of Spring HATEOAS's `Link` class, the `Affordance` class. Fundamentally,
an `Affordance` IS a `Link`:

[source,java]
----
public class Affordance extends Link {
...
}
----

It comes with extra operators that allows assembling not just links, but access to extra metadata that can
be used to serve clients, as you'll see demonstrated in this section.

For a more detailed description of "affordances" in the realm of hypermedia, checkout the following video by
Mike Amundsen.

video::W7NRMhZ4MDk[youtube]

=== Generating metadata about possible flows

Imagine defining the following domain object:

[source,java,indent=0]
----
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee]
----

This is like any other domain object with its attributes (with the boilerplate handled by Lombok's
`@Data` annotation). However, buried in the constructor call are some extra annotations:

* Jackson's `@JsonCreator` annotation flags this constructor as the one to use when Jackson creates a new object.
* Each field has a corresponding Jackson `@JsonProperty` annotation.
* Additionally, the input fields are further marked up with `@Input(required=true)`, indicated they are not
optional fields.

While these annotations aren't require for Jackson to do its thing, the Affordance API uses this additional data
to fabricate additional hypermedia.

Create a Spring Web controller like this:

[source,java,indent=0]
----
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee-controller]
...
}
----

Add a request handler for fetching all employees:

[source,java,indent=0]
----
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-all]
----

The return type is `Resources<Resource<Employee>>`. This represents a resource collection, with each element itself
also a resource. To build the collection up, you leverage the controller's `findOne()` method, which returns a
`Resource<Employee>`. With the content assembled, you can move into defining this resource's *affordances*.

The first link in any `Affordance` is the *self* link. So you can use the Affordance API's
`linkTo(methodOn(...)` methods (just like `ControllerLinkBuilder`) and create an `AffordanceBuilder` against
this method.

From there, we can take the `builder` and add more affordance using the `.add(...)` method. In this example, you
are grabbing a hold of the controller's `newEmployee()` method.

TIP: Certain mediatypes, like HAL-Forms, support _templates_. These are additional operations that work, but
generally against the same URI (the *self* link). Therefore, we are only adding affordances that also map onto
`/employees` in this method.

Return a `Resources` collection resource, making the `builder` produce a *self* link.

NOTE: Normally, you might use a Spring Data repository to actually retrieve this list of employees. However,
for simplicity, we are using a plain old Java map.

Before we test drive this, we need to define `findOne(...)` that we just saw. Add the following to your controller:

[source,java,indent=0]
----
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-one]
----

As shown in the comments, you start with an affordance for the "self" link, i.e. this method. Assuming this controller
has support to both *PUT* and *PATCH* single resources, we can create affordances for both. A key difference between
PUT and PATCH is that PUT replaces the entire record, while PATCH often is used to update individual fields. Hence,
we want to flip the parameters on `Employee` that are marked `@Input(required = true)` to false.

With this in place, we can now inspect the hypermedia generated by Spring HATEOAS.

To interrogate the collection, we just need to make a request like this:

include::{snippets}/basic/1/http-request.adoc[]

As expected, we get back a collection resource with `_embedded` and `_links`.

include::{snippets}/basic/1/response-body.adoc[]

This document is chock full of data. But when it comes time to create a new employee, what do we do?

IMPORTANT: HAL is a popular format for hypermedia. Here is where we get to see its limitations. The self link
at the bottom to `/employees` _only_ shows us the URI. It doesn't communicate ALL the operations _afforded_
to us at that URI.

To discover what we can do, we merely need to change the *Accept* header in our request, like this:

include::{snippets}/basic/2/http-request.adoc[]

This will give us a HAL-Forms document.

include::{snippets}/basic/2/response-body.adoc[]

We can see the *self* link at the top. But below that, is a *templates* section. These are operations we can perform.

Remember where we had `builder.and(linkTo(methodOn(EmployeeController.class).newEmployee(null)).rel("create"))` in
`all()`? That is what got transformed into the *default* template for *POST*, using the `@JsonCreator` and `@Input`
metadata from our domain object.

This hypermedia can be used by your website to generate an HTML FORM, hence why it's called HAL-Forms.

Remember marking marked up `findOne`? To see that, we need to navigate to an individual employee's
HAL-Form:

include::{snippets}/basic/4/http-request.adoc[]

NOTE: We skipped navigating to the HAL record for `/employees/0` and jumped straight to the HAL-Form:

include::{snippets}/basic/4/response-body.adoc[]

You can see the self link as well as the link back to the collection resource. But focusing on the self like
(which HAL-Forms apply to), there are two templates: *PUT* and *PATCH*. In PUT, both properties are required,
as depicted in your `Employee` definition. But in PATCH, they aren't.

One last piece of information on this HAL-Form. The PUT template is named *default*, since it's the first. But
the PATCH template is named *partial-update*. By default, Spring HATEOAS will name the template based on the
HTTP verb. But that was overridden using `@Action("partial-update")`, applied to the `.partialUpdate(...)`.

By using a few extra annotations on the domain object and the controller, and by chaining together some REST
methods, the Affordance API has made it possible to generate a much richer hypermedia that can simplify front
end development.
4 changes: 2 additions & 2 deletions src/main/java/org/springframework/hateoas/IanaRels.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
*/
package org.springframework.hateoas;

import lombok.experimental.UtilityClass;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;

import lombok.experimental.UtilityClass;

/**
* Static class to find out whether a relation type is defined by the IANA.
*
Expand Down
Loading