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:
1
pom.xml
1
pom.xml
@@ -64,6 +64,7 @@
|
||||
<module>hypermedia</module>
|
||||
<module>affordances</module>
|
||||
<module>simplified</module>
|
||||
<module>spring-hateoas-and-spring-data-rest</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
527
spring-hateoas-and-spring-data-rest/README.adoc
Normal file
527
spring-hateoas-and-spring-data-rest/README.adoc
Normal 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.
|
||||
104
spring-hateoas-and-spring-data-rest/pom.xml
Normal file
104
spring-hateoas-and-spring-data-rest/pom.xml
Normal 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>
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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!");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
data:
|
||||
rest:
|
||||
base-path: /api
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user