Skip to content

Commit d3bcad4

Browse files
committed
#340 - Affordances API + HAL-Forms
* Adds new Affordances API * Introduces HAL-Forms, which uses affordances to automatically generate HTML form data based on marked up domain objects and controllers Attempted rebase of review/affordances against master Original pull-request: #340, #447 Related issues: #503, #334,
1 parent 1d1f1cf commit d3bcad4

File tree

206 files changed

+22072
-507
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

206 files changed

+22072
-507
lines changed

pom.xml

+59-31
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<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">
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
34
<modelVersion>4.0.0</modelVersion>
45

56
<groupId>org.springframework.hateoas</groupId>
67
<artifactId>spring-hateoas</artifactId>
7-
<version>0.24.0.BUILD-SNAPSHOT</version>
8+
<version>0.24.0.AFFORDANCES-SNAPSHOT</version>
89

910
<name>Spring HATEOAS</name>
1011
<url>http://github.com/SpringSource/spring-hateoas</url>
@@ -69,6 +70,7 @@
6970
<properties>
7071
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
7172
<spring.version>4.3.5.RELEASE</spring.version>
73+
<spring-restdocs.version>1.2.0.RELEASE</spring-restdocs.version>
7274
<logback.version>1.1.8</logback.version>
7375
<jacoco>0.7.8</jacoco>
7476
<jacoco.destfile>${project.build.directory}/jacoco.exec</jacoco.destfile>
@@ -87,7 +89,7 @@
8789
<profile>
8890
<id>spring43-next</id>
8991
<properties>
90-
<spring.version>4.3.8.BUILD-SNAPSHOT</spring.version>
92+
<spring.version>4.3.9.BUILD-SNAPSHOT</spring.version>
9193
</properties>
9294
<repositories>
9395
<repository>
@@ -100,7 +102,8 @@
100102
<profile>
101103
<id>spring5</id>
102104
<properties>
103-
<spring.version>5.0.0.M5</spring.version>
105+
<spring.version>5.0.0.RC1</spring.version>
106+
<jackson.version>2.9.0.pr3</jackson.version>
104107
</properties>
105108
<repositories>
106109
<repository>
@@ -114,6 +117,7 @@
114117
<id>spring5-next</id>
115118
<properties>
116119
<spring.version>5.0.0.BUILD-SNAPSHOT</spring.version>
120+
<jackson.version>2.9.0.pr3</jackson.version>
117121
</properties>
118122
<repositories>
119123
<repository>
@@ -192,7 +196,6 @@
192196
<properties>
193197
<shared.resources>${project.build.directory}/shared-resources</shared.resources>
194198
<maven.install.skip>true</maven.install.skip>
195-
<skipTests>true</skipTests>
196199
<project.root>${basedir}</project.root>
197200
</properties>
198201

@@ -210,9 +213,8 @@
210213

211214
<plugins>
212215

213-
<!--
214-
Unpacks the content of spring-data-build-resources into the shared resources folder.
215-
-->
216+
<!-- Unpacks the content of spring-data-build-resources into the shared
217+
resources folder. -->
216218

217219
<plugin>
218220
<groupId>org.apache.maven.plugins</groupId>
@@ -235,9 +237,7 @@
235237
</configuration>
236238
</plugin>
237239

238-
<!--
239-
Configures JavaDoc generation.
240-
-->
240+
<!-- Configures JavaDoc generation. -->
241241

242242
<plugin>
243243
<groupId>org.apache.maven.plugins</groupId>
@@ -253,6 +253,18 @@
253253
</executions>
254254
</plugin>
255255

256+
<!-- ONLY run the **DocumentationTest test cases for Spring RestDocs -->
257+
<plugin>
258+
<groupId>org.apache.maven.plugins</groupId>
259+
<artifactId>maven-surefire-plugin</artifactId>
260+
<version>2.20</version>
261+
<configuration>
262+
<includes>
263+
<include>**/*DocumentationTest</include>
264+
</includes>
265+
</configuration>
266+
</plugin>
267+
256268
<plugin>
257269
<groupId>org.asciidoctor</groupId>
258270
<artifactId>asciidoctor-maven-plugin</artifactId>
@@ -268,12 +280,17 @@
268280
<artifactId>asciidoctorj-epub3</artifactId>
269281
<version>1.5.0-alpha.6</version>
270282
</dependency>
283+
<dependency>
284+
<groupId>org.springframework.restdocs</groupId>
285+
<artifactId>spring-restdocs-asciidoctor</artifactId>
286+
<version>${spring-restdocs.version}</version>
287+
</dependency>
271288
</dependencies>
272289
<executions>
273290

274291
<execution>
275292
<id>html</id>
276-
<phase>generate-resources</phase>
293+
<phase>prepare-package</phase>
277294
<goals>
278295
<goal>process-asciidoc</goal>
279296
</goals>
@@ -291,23 +308,13 @@
291308
</configuration>
292309
</execution>
293310

294-
<!--
295-
<execution>
296-
<id>epub</id>
297-
<phase>generate-resources</phase>
298-
<goals>
299-
<goal>process-asciidoc</goal>
300-
</goals>
301-
<configuration>
302-
<backend>epub3</backend>
303-
<sourceHighlighter>coderay</sourceHighlighter>
304-
</configuration>
305-
</execution>
306-
-->
311+
<!-- <execution> <id>epub</id> <phase>generate-resources</phase> <goals>
312+
<goal>process-asciidoc</goal> </goals> <configuration> <backend>epub3</backend>
313+
<sourceHighlighter>coderay</sourceHighlighter> </configuration> </execution> -->
307314

308315
<execution>
309316
<id>pdf</id>
310-
<phase>generate-resources</phase>
317+
<phase>prepare-package</phase>
311318
<goals>
312319
<goal>process-asciidoc</goal>
313320
</goals>
@@ -347,13 +354,15 @@
347354
<configuration>
348355
<target>
349356
<copy todir="${project.root}/target/site/reference/html">
350-
<fileset dir="${shared.resources}/asciidoc" erroronmissingdir="false">
357+
<fileset dir="${shared.resources}/asciidoc"
358+
erroronmissingdir="false">
351359
<include name="**/*.css" />
352360
</fileset>
353361
<flattenmapper />
354362
</copy>
355363
<copy todir="${project.root}/target/site/reference/html/images">
356-
<fileset dir="${basedir}/src/main/asciidoc" erroronmissingdir="false">
364+
<fileset dir="${basedir}/src/main/asciidoc"
365+
erroronmissingdir="false">
357366
<include name="**/*.png" />
358367
<include name="**/*.gif" />
359368
<include name="**/*.jpg" />
@@ -372,8 +381,12 @@
372381
<phase>process-resources</phase>
373382
<configuration>
374383
<target>
375-
<copy file="${project.build.directory}/generated-docs/index.pdf" tofile="${project.root}/target/site/reference/pdf/${project.artifactId}-reference.pdf" failonerror="false" />
376-
<copy file="${project.build.directory}/generated-docs/index.epub" tofile="${project.root}/target/site/reference/epub/${project.artifactId}-reference.epub" failonerror="false" />
384+
<copy file="${project.build.directory}/generated-docs/index.pdf"
385+
tofile="${project.root}/target/site/reference/pdf/${project.artifactId}-reference.pdf"
386+
failonerror="false" />
387+
<copy file="${project.build.directory}/generated-docs/index.epub"
388+
tofile="${project.root}/target/site/reference/epub/${project.artifactId}-reference.epub"
389+
failonerror="false" />
377390
</target>
378391
</configuration>
379392
<goals>
@@ -573,6 +586,13 @@
573586
<scope>test</scope>
574587
</dependency>
575588

589+
<dependency>
590+
<groupId>org.springframework.restdocs</groupId>
591+
<artifactId>spring-restdocs-mockmvc</artifactId>
592+
<version>${spring-restdocs.version}</version>
593+
<scope>test</scope>
594+
</dependency>
595+
576596
<dependency>
577597
<groupId>org.mockito</groupId>
578598
<artifactId>mockito-all</artifactId>
@@ -601,7 +621,15 @@
601621
<scope>test</scope>
602622
</dependency>
603623

604-
<!-- Needs to be after Jadler to make sure it sees the Servlet 3.0 dependency pulled in for testing -->
624+
<dependency>
625+
<groupId>com.jayway.jsonpath</groupId>
626+
<artifactId>json-path-assert</artifactId>
627+
<version>2.2.0</version>
628+
<scope>test</scope>
629+
</dependency>
630+
631+
<!-- Needs to be after Jadler to make sure it sees the Servlet 3.0 dependency
632+
pulled in for testing -->
605633

606634
<dependency>
607635
<groupId>javax.servlet</groupId>

src/main/asciidoc/index.adoc

+145
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,148 @@ Link link = discoverer.findLinkWithRel("foo", content);
436436
assertThat(link.getRel(), is("foo"));
437437
assertThat(link.getHref(), is("/foo/bar"));
438438
----
439+
440+
[[affordances]]
441+
== Affordances
442+
443+
The *Affordances API* provides the means to mark up your domain objects and controllers such that you can generate
444+
not only links, but additional queries with extra metadata.
445+
446+
This is done using a much richer version of Spring HATEOAS's `Link` class, the `Affordance` class. Fundamentally,
447+
an `Affordance` IS a `Link`:
448+
449+
[source,java]
450+
----
451+
public class Affordance extends Link {
452+
...
453+
}
454+
----
455+
456+
It comes with extra operators that allows assembling not just links, but access to extra metadata that can
457+
be used to serve clients, as you'll see demonstrated in this section.
458+
459+
For a more detailed description of "affordances" in the realm of hypermedia, checkout the following video by
460+
Mike Amundsen.
461+
462+
video::W7NRMhZ4MDk[youtube]
463+
464+
=== Generating metadata about possible flows
465+
466+
Imagine defining the following domain object:
467+
468+
[source,java,indent=0]
469+
----
470+
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee]
471+
----
472+
473+
This is like any other domain object with its attributes (with the boilerplate handled by Lombok's
474+
`@Data` annotation). However, buried in the constructor call are some extra annotations:
475+
476+
* Jackson's `@JsonCreator` annotation flags this constructor as the one to use when Jackson creates a new object.
477+
* Each field has a corresponding Jackson `@JsonProperty` annotation.
478+
* Additionally, the input fields are further marked up with `@Input(required=true)`, indicated they are not
479+
optional fields.
480+
481+
While these annotations aren't require for Jackson to do its thing, the Affordance API uses this additional data
482+
to fabricate additional hypermedia.
483+
484+
Create a Spring Web controller like this:
485+
486+
[source,java,indent=0]
487+
----
488+
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=employee-controller]
489+
...
490+
}
491+
----
492+
493+
Add a request handler for fetching all employees:
494+
495+
[source,java,indent=0]
496+
----
497+
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-all]
498+
----
499+
500+
The return type is `Resources<Resource<Employee>>`. This represents a resource collection, with each element itself
501+
also a resource. To build the collection up, you leverage the controller's `findOne()` method, which returns a
502+
`Resource<Employee>`. With the content assembled, you can move into defining this resource's *affordances*.
503+
504+
The first link in any `Affordance` is the *self* link. So you can use the Affordance API's
505+
`linkTo(methodOn(...)` methods (just like `ControllerLinkBuilder`) and create an `AffordanceBuilder` against
506+
this method.
507+
508+
From there, we can take the `builder` and add more affordance using the `.add(...)` method. In this example, you
509+
are grabbing a hold of the controller's `newEmployee()` method.
510+
511+
TIP: Certain mediatypes, like HAL-Forms, support _templates_. These are additional operations that work, but
512+
generally against the same URI (the *self* link). Therefore, we are only adding affordances that also map onto
513+
`/employees` in this method.
514+
515+
Return a `Resources` collection resource, making the `builder` produce a *self* link.
516+
517+
NOTE: Normally, you might use a Spring Data repository to actually retrieve this list of employees. However,
518+
for simplicity, we are using a plain old Java map.
519+
520+
Before we test drive this, we need to define `findOne(...)` that we just saw. Add the following to your controller:
521+
522+
[source,java,indent=0]
523+
----
524+
include::{baseDir}/src/test/java/org/springframework/hateoas/affordance/AffordanceDocumentationTest.java[tag=find-one]
525+
----
526+
527+
As shown in the comments, you start with an affordance for the "self" link, i.e. this method. Assuming this controller
528+
has support to both *PUT* and *PATCH* single resources, we can create affordances for both. A key difference between
529+
PUT and PATCH is that PUT replaces the entire record, while PATCH often is used to update individual fields. Hence,
530+
we want to flip the parameters on `Employee` that are marked `@Input(required = true)` to false.
531+
532+
With this in place, we can now inspect the hypermedia generated by Spring HATEOAS.
533+
534+
To interrogate the collection, we just need to make a request like this:
535+
536+
include::{snippets}/basic/1/http-request.adoc[]
537+
538+
As expected, we get back a collection resource with `_embedded` and `_links`.
539+
540+
include::{snippets}/basic/1/response-body.adoc[]
541+
542+
This document is chock full of data. But when it comes time to create a new employee, what do we do?
543+
544+
IMPORTANT: HAL is a popular format for hypermedia. Here is where we get to see its limitations. The self link
545+
at the bottom to `/employees` _only_ shows us the URI. It doesn't communicate ALL the operations _afforded_
546+
to us at that URI.
547+
548+
To discover what we can do, we merely need to change the *Accept* header in our request, like this:
549+
550+
include::{snippets}/basic/2/http-request.adoc[]
551+
552+
This will give us a HAL-Forms document.
553+
554+
include::{snippets}/basic/2/response-body.adoc[]
555+
556+
We can see the *self* link at the top. But below that, is a *templates* section. These are operations we can perform.
557+
558+
Remember where we had `builder.and(linkTo(methodOn(EmployeeController.class).newEmployee(null)).rel("create"))` in
559+
`all()`? That is what got transformed into the *default* template for *POST*, using the `@JsonCreator` and `@Input`
560+
metadata from our domain object.
561+
562+
This hypermedia can be used by your website to generate an HTML FORM, hence why it's called HAL-Forms.
563+
564+
Remember marking marked up `findOne`? To see that, we need to navigate to an individual employee's
565+
HAL-Form:
566+
567+
include::{snippets}/basic/4/http-request.adoc[]
568+
569+
NOTE: We skipped navigating to the HAL record for `/employees/0` and jumped straight to the HAL-Form:
570+
571+
include::{snippets}/basic/4/response-body.adoc[]
572+
573+
You can see the self link as well as the link back to the collection resource. But focusing on the self like
574+
(which HAL-Forms apply to), there are two templates: *PUT* and *PATCH*. In PUT, both properties are required,
575+
as depicted in your `Employee` definition. But in PATCH, they aren't.
576+
577+
One last piece of information on this HAL-Form. The PUT template is named *default*, since it's the first. But
578+
the PATCH template is named *partial-update*. By default, Spring HATEOAS will name the template based on the
579+
HTTP verb. But that was overridden using `@Action("partial-update")`, applied to the `.partialUpdate(...)`.
580+
581+
By using a few extra annotations on the domain object and the controller, and by chaining together some REST
582+
methods, the Affordance API has made it possible to generate a much richer hypermedia that can simplify front
583+
end development.

src/main/java/org/springframework/hateoas/IanaRels.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
*/
1616
package org.springframework.hateoas;
1717

18-
import lombok.experimental.UtilityClass;
19-
2018
import java.util.Arrays;
2119
import java.util.Collection;
2220
import java.util.Collections;
2321
import java.util.HashSet;
2422

23+
import lombok.experimental.UtilityClass;
24+
2525
/**
2626
* Static class to find out whether a relation type is defined by the IANA.
2727
*

0 commit comments

Comments
 (0)