Introduce Spring Data REST + Spring HATEOAS example.

Show how to integrate custom controller operations with Spring Data REST-provided ones.
This commit is contained in:
Greg Turnquist
2019-08-20 13:50:28 -05:00
parent c118a4d1eb
commit 83e8ebb40c
13 changed files with 1228 additions and 0 deletions

View File

@@ -64,6 +64,7 @@
<module>hypermedia</module>
<module>affordances</module>
<module>simplified</module>
<module>spring-hateoas-and-spring-data-rest</module>
</modules>
<properties>

View File

@@ -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<Order, Long> {
}
----
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<T>` as the means to define a post processor.
In this case, you'd be interested in post processing
`EntityModel<Order>` objects (instead of just `Order` objects).
Like this:
====
[source,java,tabsize=2]
----
@Component
public class OrderProcessor implements RepresentationModelProcessor<EntityModel<Order>> { // <1>
private final RepositoryRestConfiguration configuration;
public OrderProcessor(RepositoryRestConfiguration configuration) { // <2>
this.configuration = configuration;
}
...
}
----
<1> Implementing `RepresentationModelProcessor` for `EntityModel<Order>` 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<Order> process(EntityModel<Order> 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.

View File

@@ -0,0 +1,104 @@
<?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">
<modelVersion>4.0.0</modelVersion>
<artifactId>spring-hateoas-examples-spring-data-rest</artifactId>
<name>Spring HATEOAS - Examples - Spring Data REST</name>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.hateoas.examples</groupId>
<artifactId>spring-hateoas-examples</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.hateoas.examples</groupId>
<artifactId>commons</artifactId>
<version>1.0.0.BUILD-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-webtestclient</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>1.5.3</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>html</backend>
<doctype>book</doctype>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>${spring-restdocs.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.7</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>
${project.build.outputDirectory}/static/docs
</outputDirectory>
<resources>
<resource>
<directory>
${project.build.directory}/generated-docs
</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

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

View File

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

View File

@@ -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 + '\'' +
'}';
}
}

View File

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

View File

@@ -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<EntityModel<Order>> {
private final RepositoryRestConfiguration configuration;
public OrderProcessor(RepositoryRestConfiguration configuration) {
this.configuration = configuration;
}
@Override
public EntityModel<Order> process(EntityModel<Order> 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());
}
}

View File

@@ -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<Order, Long> {
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
spring:
data:
rest:
base-path: /api

View File

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