diff --git a/pom.xml b/pom.xml
index 5f1e097..42fcc86 100644
--- a/pom.xml
+++ b/pom.xml
@@ -64,6 +64,7 @@
hypermedia
affordances
simplified
+ spring-hateoas-and-spring-data-rest
diff --git a/spring-hateoas-and-spring-data-rest/README.adoc b/spring-hateoas-and-spring-data-rest/README.adoc
new file mode 100644
index 0000000..dbdd1d2
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/README.adoc
@@ -0,0 +1,527 @@
+= Spring HATEOAS - Combined with Spring Data REST
+
+A common dilemna for people familiar with both Spring HATEAOS as well as Spring Data REST is deciding which to use.
+You can use both, in the same application.
+This section will show how.
+
+This example will borrow the concept found in Oliver Drotbohm's https://github.com/odrotbohm/spring-restbucks[Spring RESTbucks] example--a coffee shop fulfilling orders.
+
+== Defining the problem
+
+PROBLEM:
+You wish to implement the concept of orders.
+These orders have certain status codes which dictate what transitions the system can take, e.g. an order can't be fulfilled until it's paid for, and a fulfilled order can't be cancelled.
+
+SOLUTION:
+You must encode a set of `OrderStatus` codes, and enforce them using a custom Spring Web MVC controller.
+This controller should have routes that appear alongside the ones provided by Spring Data REST.
+
+== Getting off the ground
+
+To build this example, you need:
+
+* Spring Data REST
+* Spring HATEAOS
+* Spring Data JPA
+* H2
+
+IMPORTANT: It's especially easy if you use http://start.spring.io and pick *Rest Repositories*, *Spring HATEOAS*, *Spring Data JPA*, and *H2 Database*.
+
+Assuming you have an empty project with these dependencies, you can get underway.
+
+Before you can code a custom controller, you need the basics of an ordering system.
+And that starts with a domain object:
+
+====
+[source,java,tabsize=2]
+----
+@Entity
+@Table(name = "ORDERS") // <1>
+class Order {
+
+ @Id @GeneratedValue
+ private Long id; // <2>
+
+ private OrderStatus orderStatus; // <3>
+
+ private String description; // <4>
+
+ private Order() {
+ this.id = null;
+ this.orderStatus = OrderStatus.BEING_CREATED;
+ this.description = "";
+ }
+
+ public Order(String description) {
+ this();
+ this.description = description;
+ }
+
+ ...
+}
+----
+<1> In SQL, `ORDER` is a reserved word.
+Spring Data defaults to calling the table `order` which requires the override using JPA's `@Table` annotation.
+<2> The `id` field is your primary key set to automatic generation.
+<3> Every `Order` has an `OrderStatus`, a value type that embodies the current state.
+<4> `description` represents the rest of the POJOs data.
+
+Also shown are two constructors: an private no-arg one to support JPA and one used by you to create new entries.
+====
+
+The next step in defining your domain is to define `OrderStatus`.
+Assuming we want a general flow of `Create an order` => `Pay for an order` => `Fulfill an order`, with the option to cancel only if you have not yet paid for it, this will do nicely:
+
+====
+[source,java,tabsize=2]
+----
+public enum OrderStatus {
+
+ BEING_CREATED, PAID_FOR, FULFILLED, CANCELLED;
+
+ /**
+ * Verify the transition between {@link OrderStatus} is valid.
+ *
+ * NOTE: This is where any/all rules for state transitions should be kept and enforced.
+ */
+ static boolean valid(OrderStatus currentStatus, OrderStatus newStatus) {
+
+ if (currentStatus == BEING_CREATED) {
+ return newStatus == PAID_FOR || newStatus == CANCELLED;
+ } else if (currentStatus == PAID_FOR) {
+ return newStatus == FULFILLED;
+ } else if (currentStatus == FULFILLED) {
+ return false;
+ } else if (currentStatus == CANCELLED) {
+ return false;
+ } else {
+ throw new RuntimeException("Unrecognized situation.");
+ }
+ }
+}
+----
+
+At the top are the actual states.
+At the bottom is a static validation method.
+This is where the rules of state transitions are defined, and where the rest of the system should look to discern whether or not a transition is valid.
+====
+
+The last step to get off the ground is a Spring Data repository definition:
+
+====
+[source,java,tabsize=2]
+----
+public interface OrderRepository extends CrudRepository {
+
+}
+----
+
+This repository extends Spring Data Commons' `CrudRepository`, filling in the domain and key types (`Order` and `Long`).
+For this example, there is no need for custom finder methods.
+====
+
+For good measure, why don't you preload some data?
+
+====
+[source,java,tabsize=2]
+----
+@Component
+public class DatabaseLoader {
+
+ @Bean
+ CommandLineRunner init(OrderRepository repository) { // <1>
+
+ return args -> { // <2>
+ repository.save(new Order("grande mocha")); // <3>
+ repository.save(new Order("venti hazelnut machiatto"));
+ };
+ }
+}
+----
+<1> Returning a `CommandLineRunner` as a Spring bean will result in an object that Spring Boot invokes when the app finished starting up.
+<2> Since `CommandLineRunner` is a Java 8 functional interface, you may simply return a lambda expression instead of creating an instance.
+<3> Inside the lambda, you can leverage the injected `OrderRepository` to create a couple initial orders.
+====
+
+Since you're building an API, why not serve it at the root path of `/api`?
+To do so, you need to create an `application.yml`:
+
+.`src/main/resources/application.yml`
+====
+[source,yaml,tabsize=2]
+----
+spring:
+ data:
+ rest:
+ base-path: /api
+----
+====
+
+It should be stated that right here, you can launch your application.
+Spring Boot will launch the web container, preload the data, and then bring Spring Data REST online.
+Spring Data REST with all of its prebuilt, hypermedia-powered routes, will respond to calls to create, replace, update and delete `Order` objects.
+
+But Spring Data REST will know nothing of valid and invalid state transitions.
+It's pre-built links will help you navigate from `/api` to the aggregate root for all orders, to individual entries, and back.
+But there will no concept of paying for, fulfilling, or cancelling orders.
+At least, not embedded in the hypermedia.
+The only hint end users may have are the payloads of the existing orders.
+
+And that's not effective.
+
+No, it's better to create some extra operations and then serve up their links _when appropriate_.
+
+== Creating custom operations
+
+For starters, you can create a custom controller that is registered under the same `/api` like this:
+
+====
+[source,java,tabsize=2]
+----
+@BasePathAwareController // <1>
+public class CustomOrderController {
+
+ private final OrderRepository repository;
+
+ public CustomOrderController(OrderRepository repository) { // <2>
+ this.repository = repository;
+ }
+
+ ...
+}
+----
+<1> Spring Data REST's `@BasePathAwareController` is used to denote a `@RestController` that only wishes to have the same base path (`/api` in this example).
+<2> The controller receives a copy of the `OrderRepository` via *constructor injection*.
+====
+
+To add a method that supports making payments could look like this:
+
+====
+[source,java,tabsize=2,indent=0]
+----
+ @PostMapping("/orders/{id}/pay") // <1>
+ ResponseEntity> pay(@PathVariable Long id) { // <2>
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id)); // <3>
+
+ if (valid(order.getOrderStatus(), OrderStatus.PAID_FOR)) { // <4>
+
+ order.setOrderStatus(OrderStatus.PAID_FOR);
+ return ResponseEntity.ok(repository.save(order)); // <5>
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.PAID_FOR + " is not valid."); // <6>
+ }
+----
+<1> Invoking `POST /orders/{id}/pay` is a signal for end users to signal "I want to pay for this".
+<2> Spring MVC decodes the `{id}` piece of the URI into an `id` argument.
+<3> Use `OrderRepository` to retrieve the current `Order` or throw an exception.
+<4> Check if the transition from the order's current status to `PAID_FOR` is valid.
+<5> If it is valid, update the order's status and save it back to the database.
+<6> If it is _not_ valid, return an HTTP Bad Request status code with details about the requested transition in the response body.
+
+NOTE: It's important to note this only shows transitioning to a different state, i.e. `OrderStatus`.
+It doesn't carry the concept of collecting payment and thus doesn't denote currency.
+====
+
+I suggest reading that method a couple more times.
+If you grok it, then the following operations should make perfect sense:
+
+====
+[source,java,tabsize=2,indent=0]
+----
+ @PostMapping("/orders/{id}/cancel")
+ ResponseEntity> cancel(@PathVariable Long id) {
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
+
+ if (valid(order.getOrderStatus(), OrderStatus.CANCELLED)) {
+
+ order.setOrderStatus(OrderStatus.CANCELLED);
+ return ResponseEntity.ok(repository.save(order));
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.CANCELLED + " is not valid.");
+ }
+
+ @PostMapping("/orders/{id}/fulfill")
+ ResponseEntity> fulfill(@PathVariable Long id) {
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
+
+ if (valid(order.getOrderStatus(), OrderStatus.FULFILLED)) {
+
+ order.setOrderStatus(OrderStatus.FULFILLED);
+ return ResponseEntity.ok(repository.save(order));
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.FULFILLED + " is not valid.");
+ }
+----
+====
+
+This is how you code the transitions and ensuring they are only carried out if valid.
+But it doesn't alter the hypermedia served by Spring Data REST.
+Hence, end users _still_ don't know about the extra operations nor if they are appropriate or not.
+
+== Altering what Spring Data REST is serving
+
+That requires creating something that can alter the object before it gets serialized.
+Spring HATEOAS provides a
+`RepresentationModelProcessor` as the means to define a post processor.
+In this case, you'd be interested in post processing
+`EntityModel` objects (instead of just `Order` objects).
+
+Like this:
+
+====
+[source,java,tabsize=2]
+----
+@Component
+public class OrderProcessor implements RepresentationModelProcessor> { // <1>
+
+ private final RepositoryRestConfiguration configuration;
+
+ public OrderProcessor(RepositoryRestConfiguration configuration) { // <2>
+ this.configuration = configuration;
+ }
+
+ ...
+}
+----
+<1> Implementing `RepresentationModelProcessor` for `EntityModel` will gives us a handle on the hypermedia endowed object Spring Data REST assembles.
+<2> We need a copy of Spring Data REST's `RepositoryRestConfiguration` bean in order to know the *base path*.
+The way we use it will be shown below.
+====
+
+This interface only has one method to implement, `T process(T model)`, where you can augment (or completely replace) the "thing" before it gets serialized.
+
+Check it out:
+
+====
+[source,java,tabsize=2,indent=0]
+----
+ @Override
+ public EntityModel process(EntityModel model) {
+
+ CustomOrderController controller = methodOn(CustomOrderController.class); // <1>
+ String basePath = configuration.getBasePath().toString(); // <2>
+
+ // If PAID_FOR is valid, add a link to the `pay()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.PAID_FOR)) {
+ model.add(applyBasePath( //
+ linkTo(controller.pay(model.getContent().getId())) //
+ .withRel(IanaLinkRelations.PAYMENT), //
+ basePath));
+ }
+
+ // If CANCELLED is valid, add a link to the `cancel()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.CANCELLED)) {
+ model.add(applyBasePath( //
+ linkTo(controller.cancel(model.getContent().getId())) //
+ .withRel(LinkRelation.of("cancel")), //
+ basePath));
+ }
+
+ // If FULFILLED is valid, add a link to the `fulfill()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.FULFILLED)) {
+ model.add(applyBasePath( //
+ linkTo(controller.fulfill(model.getContent().getId())) //
+ .withRel(LinkRelation.of("fulfill")), //
+ basePath));
+ }
+
+ return model;
+ }
+----
+<1> Get a hold of the `CustomOrderController` class with your custom `pay`, `cancel`, and `fulfill` methods.
+<2> Look up the `basePath` Spring Data REST has been configured with.
+
+Check the `model` object's payload (an `Order` object) for the current `OrderStatus`.
+Check if `PAID_FOR` is valid.
+If so, add a link to that method.
+Repeat for the other two state transitions.
+====
+
+If you'll notice, there is another function, `applyBasePath`, used for each of these links.
+A gap between Spring HATEOAS and Spring Data REST is that Spring HATEAOS knows nothing about Spring Data REST's `basePath` setting.
+Hence, when you build a link to `CustomerOrderController`, it won't know about
+`@BasePathAwareController`.
+So you have to put in yourself (for now).
+
+IMPORTANT: It's to implement this.
+Otherwise, end users will only see links to `/orders/{id}/pay`, but the controller will expect `/api/orders/{id}/pay`
+
+====
+[source,java,tabsize=2,indent=0]
+----
+ /**
+ * Adjust the {@link Link} such that it starts at {@literal basePath}.
+ *
+ * @param link - link presumably supplied via Spring HATEOAS
+ * @param basePath - base path provided by Spring Data REST
+ * @return new {@link Link} with these two values melded together
+ */
+ private static Link applyBasePath(Link link, String basePath) {
+
+ URI uri = link.toUri();
+
+ URI newUri = null;
+ try {
+ newUri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), //
+ uri.getPort(), basePath + uri.getPath(), uri.getQuery(), uri.getFragment());
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ }
+
+ return new Link(newUri.toString(), link.getRel());
+ }
+----
+
+This functional essentially extracts the URI of the incoming `link`, inserts the `basePath` at the front of it's path, and then fashions a new Spring HATEAOS `link.
+====
+
+== Interacting with your API
+
+With this, you can easily run things, and see your conditional links appear:
+
+====
+----
+$ curl localhost:8080/api/orders/1
+{
+ "orderStatus" : "BEING_CREATED",
+ "description" : "grande mocha",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ },
+ "order" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ },
+ "payment" : {
+ "href" : "http://localhost:8080/api/orders/1/pay"
+ },
+ "cancel" : {
+ "href" : "http://localhost:8080/api/orders/1/cancel"
+ }
+ }
+}
+----
+====
+
+Apply the payment link:
+
+====
+----
+$ curl -X POST localhost:8080/api/orders/1/pay
+{
+ "id" : 1,
+ "orderStatus" : "PAID_FOR",
+ "description" : "grande mocha"
+}
+
+$ curl localhost:8080/api/orders/1
+{
+ "orderStatus" : "PAID_FOR",
+ "description" : "grande mocha",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ },
+ "order" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ },
+ "fulfill" : {
+ "href" : "http://localhost:8080/api/orders/1/fulfill"
+ }
+ }
+}
+----
+====
+
+The `pay` and `cancel` links have disappeared, replaced with a `fulfill` link.
+Fulfill the order and see the final state:
+
+====
+----
+$ curl -X POST localhost:8080/api/orders/1/fulfill
+{
+ "id" : 1,
+ "orderStatus" : "FULFILLED",
+ "description" : "grande mocha"
+}
+
+$ curl localhost:8080/api/orders/1
+{
+ "orderStatus" : "FULFILLED",
+ "description" : "grande mocha",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ },
+ "order" : {
+ "href" : "http://localhost:8080/api/orders/1"
+ }
+ }
+}
+----
+====
+
+The first drink order has been fulfilled.
+If you cancel the second order, you can see what _it's_ links tell you:
+
+====
+----
+$ curl localhost:8080/api/orders/2
+{
+ "orderStatus" : "BEING_CREATED",
+ "description" : "venti hazelnut machiatto",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost:8080/api/orders/2"
+ },
+ "order" : {
+ "href" : "http://localhost:8080/api/orders/2"
+ },
+ "payment" : {
+ "href" : "http://localhost:8080/api/orders/2/pay"
+ },
+ "cancel" : {
+ "href" : "http://localhost:8080/api/orders/2/cancel"
+ }
+ }
+}
+
+$ curl -X POST localhost:8080/api/orders/2/cancel
+{
+ "id" : 2,
+ "orderStatus" : "CANCELLED",
+ "description" : "venti hazelnut machiatto"
+}
+
+$ curl localhost:8080/api/orders/2
+{
+ "orderStatus" : "CANCELLED",
+ "description" : "venti hazelnut machiatto",
+ "_links" : {
+ "self" : {
+ "href" : "http://localhost:8080/api/orders/2"
+ },
+ "order" : {
+ "href" : "http://localhost:8080/api/orders/2"
+ }
+ }
+}
+----
+====
+
+It has reached a different end state, and the links guided you the whole way.
+
+== Conclusion
+
+This is what it takes to create custom, conditional, hypermedia-based routes, and tied them into the unconditional ones provided by Spring Data REST.
+Seamlessly.
+By letting Spring Data REST do the heavy lifting, you are freed up to work on such business-oriented logic when building a resilient API.
diff --git a/spring-hateoas-and-spring-data-rest/pom.xml b/spring-hateoas-and-spring-data-rest/pom.xml
new file mode 100644
index 0000000..108d75d
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/pom.xml
@@ -0,0 +1,104 @@
+
+
+ 4.0.0
+
+ spring-hateoas-examples-spring-data-rest
+ Spring HATEOAS - Examples - Spring Data REST
+ jar
+
+
+ org.springframework.hateoas.examples
+ spring-hateoas-examples
+ 1.0.0.BUILD-SNAPSHOT
+
+
+
+
+ org.springframework.hateoas.examples
+ commons
+ 1.0.0.BUILD-SNAPSHOT
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-rest
+
+
+
+ org.springframework.restdocs
+ spring-restdocs-webtestclient
+ test
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.asciidoctor
+ asciidoctor-maven-plugin
+ 1.5.3
+
+
+ generate-docs
+ prepare-package
+
+ process-asciidoc
+
+
+ html
+ book
+
+
+
+
+
+ org.springframework.restdocs
+ spring-restdocs-asciidoctor
+ ${spring-restdocs.version}
+
+
+
+
+
+ maven-resources-plugin
+ 2.7
+
+
+ copy-resources
+ prepare-package
+
+ copy-resources
+
+
+
+ ${project.build.outputDirectory}/static/docs
+
+
+
+
+ ${project.build.directory}/generated-docs
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/CustomOrderController.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/CustomOrderController.java
new file mode 100644
index 0000000..093080a
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/CustomOrderController.java
@@ -0,0 +1,82 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import static org.springframework.hateoas.examples.OrderStatus.*;
+
+import org.springframework.data.rest.webmvc.BasePathAwareController;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+
+/**
+ * @author Greg Turnquist
+ */
+@BasePathAwareController
+public class CustomOrderController {
+
+ private final OrderRepository repository;
+
+ public CustomOrderController(OrderRepository repository) {
+ this.repository = repository;
+ }
+
+ @PostMapping("/orders/{id}/pay")
+ ResponseEntity> pay(@PathVariable Long id) {
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
+
+ if (valid(order.getOrderStatus(), OrderStatus.PAID_FOR)) {
+
+ order.setOrderStatus(OrderStatus.PAID_FOR);
+ return ResponseEntity.ok(repository.save(order));
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.PAID_FOR + " is not valid.");
+ }
+
+ @PostMapping("/orders/{id}/cancel")
+ ResponseEntity> cancel(@PathVariable Long id) {
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
+
+ if (valid(order.getOrderStatus(), OrderStatus.CANCELLED)) {
+
+ order.setOrderStatus(OrderStatus.CANCELLED);
+ return ResponseEntity.ok(repository.save(order));
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.CANCELLED + " is not valid.");
+ }
+
+ @PostMapping("/orders/{id}/fulfill")
+ ResponseEntity> fulfill(@PathVariable Long id) {
+
+ Order order = this.repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
+
+ if (valid(order.getOrderStatus(), OrderStatus.FULFILLED)) {
+
+ order.setOrderStatus(OrderStatus.FULFILLED);
+ return ResponseEntity.ok(repository.save(order));
+ }
+
+ return ResponseEntity.badRequest()
+ .body("Transitioning from " + order.getOrderStatus() + " to " + OrderStatus.FULFILLED + " is not valid.");
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java
new file mode 100644
index 0000000..faf6b91
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java
@@ -0,0 +1,37 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author Greg Turnquist
+ */
+@Component
+public class DatabaseLoader {
+
+ @Bean
+ CommandLineRunner init(OrderRepository repository) {
+
+ return args -> {
+ repository.save(new Order("grande mocha"));
+ repository.save(new Order("venti hazelnut machiatto"));
+ };
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/Order.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/Order.java
new file mode 100644
index 0000000..e94c72d
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/Order.java
@@ -0,0 +1,101 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.util.Objects;
+
+/**
+ * @author Greg Turnquist
+ */
+@Entity
+@Table(name = "ORDERS")
+class Order {
+
+ @Id @GeneratedValue
+ private Long id;
+
+ private OrderStatus orderStatus;
+
+ private String description;
+
+ private Order() {
+ this.id = null;
+ this.orderStatus = OrderStatus.BEING_CREATED;
+ this.description = "";
+ }
+
+ public Order(String description) {
+ this();
+ this.description = description;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public OrderStatus getOrderStatus() {
+ return orderStatus;
+ }
+
+ public void setOrderStatus(OrderStatus orderStatus) {
+ this.orderStatus = orderStatus;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Order order = (Order) o;
+ return Objects.equals(id, order.id) &&
+ orderStatus == order.orderStatus &&
+ Objects.equals(description, order.description);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, orderStatus, description);
+ }
+
+ @Override
+ public String toString() {
+ return "Order{" +
+ "id=" + id +
+ ", orderStatus=" + orderStatus +
+ ", description='" + description + '\'' +
+ '}';
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderNotFoundException.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderNotFoundException.java
new file mode 100644
index 0000000..e7645df
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderNotFoundException.java
@@ -0,0 +1,26 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+/**
+ * @author Greg Turnquist
+ */
+class OrderNotFoundException extends RuntimeException {
+
+ public OrderNotFoundException(Long id) {
+ super("Order " + id + " not found!");
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderProcessor.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderProcessor.java
new file mode 100644
index 0000000..f96475d
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderProcessor.java
@@ -0,0 +1,102 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import static org.springframework.hateoas.examples.OrderStatus.*;
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.LinkRelation;
+import org.springframework.hateoas.server.RepresentationModelProcessor;
+import org.springframework.stereotype.Component;
+
+/**
+ * A {@link RepresentationModelProcessor} that takes an {@link Order} that has been wrapped by Spring Data REST into an
+ * {@link EntityModel} and applies custom Spring HATEAOS-based {@link Link}s based on the state.
+ *
+ * @author Greg Turnquist
+ */
+@Component
+public class OrderProcessor implements RepresentationModelProcessor> {
+
+ private final RepositoryRestConfiguration configuration;
+
+ public OrderProcessor(RepositoryRestConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ @Override
+ public EntityModel process(EntityModel model) {
+
+ CustomOrderController controller = methodOn(CustomOrderController.class);
+ String basePath = configuration.getBasePath().toString();
+
+ // If PAID_FOR is valid, add a link to the `pay()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.PAID_FOR)) {
+ model.add(applyBasePath( //
+ linkTo(controller.pay(model.getContent().getId())) //
+ .withRel(IanaLinkRelations.PAYMENT), //
+ basePath));
+ }
+
+ // If CANCELLED is valid, add a link to the `cancel()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.CANCELLED)) {
+ model.add(applyBasePath( //
+ linkTo(controller.cancel(model.getContent().getId())) //
+ .withRel(LinkRelation.of("cancel")), //
+ basePath));
+ }
+
+ // If FULFILLED is valid, add a link to the `fulfill()` method
+ if (valid(model.getContent().getOrderStatus(), OrderStatus.FULFILLED)) {
+ model.add(applyBasePath( //
+ linkTo(controller.fulfill(model.getContent().getId())) //
+ .withRel(LinkRelation.of("fulfill")), //
+ basePath));
+ }
+
+ return model;
+ }
+
+ /**
+ * Adjust the {@link Link} such that it starts at {@literal basePath}.
+ *
+ * @param link - link presumably supplied via Spring HATEOAS
+ * @param basePath - base path provided by Spring Data REST
+ * @return new {@link Link} with these two values melded together
+ */
+ private static Link applyBasePath(Link link, String basePath) {
+
+ URI uri = link.toUri();
+
+ URI newUri = null;
+ try {
+ newUri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), //
+ uri.getPort(), basePath + uri.getPath(), uri.getQuery(), uri.getFragment());
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ }
+
+ return new Link(newUri.toString(), link.getRel());
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderRepository.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderRepository.java
new file mode 100644
index 0000000..cb1fb1a
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderRepository.java
@@ -0,0 +1,26 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import org.springframework.data.repository.CrudRepository;
+
+/**
+ * @author Greg Turnquist
+ */
+public interface OrderRepository extends CrudRepository {
+
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderStatus.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderStatus.java
new file mode 100644
index 0000000..8433816
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderStatus.java
@@ -0,0 +1,44 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+/**
+ * @author Greg Turnquist
+ */
+public enum OrderStatus {
+
+ BEING_CREATED, PAID_FOR, FULFILLED, CANCELLED;
+
+ /**
+ * Verify the transition between {@link OrderStatus} is valid. NOTE: This is where any/all rules for state transitions
+ * should be kept and enforced.
+ */
+ static boolean valid(OrderStatus currentStatus, OrderStatus newStatus) {
+
+ if (currentStatus == BEING_CREATED) {
+ return newStatus == PAID_FOR || newStatus == CANCELLED;
+ } else if (currentStatus == PAID_FOR) {
+ return newStatus == FULFILLED;
+ } else if (currentStatus == FULFILLED) {
+ return false;
+ } else if (currentStatus == CANCELLED) {
+ return false;
+ } else {
+ throw new RuntimeException("Unrecognized situation.");
+ }
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/SpringHateoasSpringDataRestApplication.java b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/SpringHateoasSpringDataRestApplication.java
new file mode 100644
index 0000000..6a1fc42
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/SpringHateoasSpringDataRestApplication.java
@@ -0,0 +1,31 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Greg Turnquist
+ */
+@SpringBootApplication
+public class SpringHateoasSpringDataRestApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringHateoasSpringDataRestApplication.class);
+ }
+}
diff --git a/spring-hateoas-and-spring-data-rest/src/main/resources/application.yml b/spring-hateoas-and-spring-data-rest/src/main/resources/application.yml
new file mode 100644
index 0000000..594e0f8
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/main/resources/application.yml
@@ -0,0 +1,4 @@
+spring:
+ data:
+ rest:
+ base-path: /api
\ No newline at end of file
diff --git a/spring-hateoas-and-spring-data-rest/src/test/java/org/springframework/hateoas/examples/OrderIntegrationTest.java b/spring-hateoas-and-spring-data-rest/src/test/java/org/springframework/hateoas/examples/OrderIntegrationTest.java
new file mode 100644
index 0000000..a73081f
--- /dev/null
+++ b/spring-hateoas-and-spring-data-rest/src/test/java/org/springframework/hateoas/examples/OrderIntegrationTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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
+ *
+ * https://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.examples;
+
+import static org.hamcrest.Matchers.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.hateoas.MediaTypes;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * @author Greg Turnquist
+ */
+@SpringBootTest()
+@AutoConfigureMockMvc
+public class OrderIntegrationTest {
+
+ @Autowired MockMvc mvc;
+
+ @Test
+ void basics() throws Exception {
+
+ // Core operations provided by Spring Data REST
+
+ this.mvc.perform(get("/api")) //
+ .andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaTypes.HAL_JSON)) //
+ .andExpect(jsonPath("$._links.orders.href", is("http://localhost/api/orders")))
+ .andExpect(jsonPath("$._links.profile.href", is("http://localhost/api/profile")));
+
+ this.mvc.perform(get("/api/orders")).andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaTypes.HAL_JSON)) //
+ .andExpect(jsonPath("$._embedded.orders[0].orderStatus", is("BEING_CREATED")))
+ .andExpect(jsonPath("$._embedded.orders[0].description", is("grande mocha")))
+ .andExpect(jsonPath("$._embedded.orders[0]._links.self.href", is("http://localhost/api/orders/1")))
+ .andExpect(jsonPath("$._embedded.orders[0]._links.order.href", is("http://localhost/api/orders/1")))
+ .andExpect(jsonPath("$._embedded.orders[0]._links.payment.href", is("http://localhost/api/orders/1/pay")))
+ .andExpect(jsonPath("$._embedded.orders[0]._links.cancel.href", is("http://localhost/api/orders/1/cancel")))
+ .andExpect(jsonPath("$._embedded.orders[1].orderStatus", is("BEING_CREATED")))
+ .andExpect(jsonPath("$._embedded.orders[1].description", is("venti hazelnut machiatto")))
+ .andExpect(jsonPath("$._embedded.orders[1]._links.self.href", is("http://localhost/api/orders/2")))
+ .andExpect(jsonPath("$._embedded.orders[1]._links.order.href", is("http://localhost/api/orders/2")))
+ .andExpect(jsonPath("$._embedded.orders[1]._links.payment.href", is("http://localhost/api/orders/2/pay")))
+ .andExpect(jsonPath("$._embedded.orders[1]._links.cancel.href", is("http://localhost/api/orders/2/cancel")))
+ .andExpect(jsonPath("$._links.self.href", is("http://localhost/api/orders")))
+ .andExpect(jsonPath("$._links.profile.href", is("http://localhost/api/profile/orders")));
+
+ // Fulfilling an unpaid-for order should fail.
+
+ this.mvc.perform(post("/api/orders/1/fulfill")) //
+ .andDo(print()) //
+ .andExpect(status().is4xxClientError()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(content().string("\"Transitioning from BEING_CREATED to FULFILLED is not valid.\""));
+
+ // Pay for the order.
+
+ this.mvc.perform(post("/api/orders/1/pay")) //
+ .andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(jsonPath("$.id", is(1))) //
+ .andExpect(jsonPath("$.orderStatus", is("PAID_FOR")));
+
+ // Paying for an already paid-for order should fail.
+
+ this.mvc.perform(post("/api/orders/1/pay")) //
+ .andDo(print()) //
+ .andExpect(status().is4xxClientError()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(content().string("\"Transitioning from PAID_FOR to PAID_FOR is not valid.\""));
+
+ // Cancelling a paid-for order should fail.
+
+ this.mvc.perform(post("/api/orders/1/cancel")) //
+ .andDo(print()) //
+ .andExpect(status().is4xxClientError()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(content().string("\"Transitioning from PAID_FOR to CANCELLED is not valid.\""));
+
+ // Verify a paid-for order now shows links to fulfill.
+
+ this.mvc.perform(get("/api/orders/1")) //
+ .andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaTypes.HAL_JSON)) //
+ .andExpect(jsonPath("$.orderStatus", is("PAID_FOR"))) //
+ .andExpect(jsonPath("$.description", is("grande mocha"))) //
+ .andExpect(jsonPath("$._links.self.href", is("http://localhost/api/orders/1")))
+ .andExpect(jsonPath("$._links.order.href", is("http://localhost/api/orders/1")))
+ .andExpect(jsonPath("$._links.fulfill.href", is("http://localhost/api/orders/1/fulfill")));
+
+ // Fulfill the order.
+
+ this.mvc.perform(post("/api/orders/1/fulfill")) //
+ .andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(jsonPath("$.orderStatus", is("FULFILLED"))) //
+ .andExpect(jsonPath("$.description", is("grande mocha")));
+
+ // Cancelling a fulfilled order should fail.
+
+ this.mvc.perform(post("/api/orders/1/cancel")) //
+ .andDo(print()) //
+ .andExpect(status().is4xxClientError()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(content().string("\"Transitioning from FULFILLED to CANCELLED is not valid.\""));
+
+ // Cancel an order.
+
+ this.mvc.perform(post("/api/orders/2/cancel")) //
+ .andDo(print()) //
+ .andExpect(status().isOk()) //
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //
+ .andExpect(jsonPath("$.orderStatus", is("CANCELLED"))) //
+ .andExpect(jsonPath("$.description", is("venti hazelnut machiatto")));
+ }
+
+}