From 83e8ebb40c03f698e2d569ad7e4fce712f04363f Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Tue, 20 Aug 2019 13:50:28 -0500 Subject: [PATCH] Introduce Spring Data REST + Spring HATEOAS example. Show how to integrate custom controller operations with Spring Data REST-provided ones. --- pom.xml | 1 + .../README.adoc | 527 ++++++++++++++++++ spring-hateoas-and-spring-data-rest/pom.xml | 104 ++++ .../examples/CustomOrderController.java | 82 +++ .../hateoas/examples/DatabaseLoader.java | 37 ++ .../hateoas/examples/Order.java | 101 ++++ .../examples/OrderNotFoundException.java | 26 + .../hateoas/examples/OrderProcessor.java | 102 ++++ .../hateoas/examples/OrderRepository.java | 26 + .../hateoas/examples/OrderStatus.java | 44 ++ ...pringHateoasSpringDataRestApplication.java | 31 ++ .../src/main/resources/application.yml | 4 + .../examples/OrderIntegrationTest.java | 143 +++++ 13 files changed, 1228 insertions(+) create mode 100644 spring-hateoas-and-spring-data-rest/README.adoc create mode 100644 spring-hateoas-and-spring-data-rest/pom.xml create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/CustomOrderController.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/DatabaseLoader.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/Order.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderNotFoundException.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderProcessor.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderRepository.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/OrderStatus.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/java/org/springframework/hateoas/examples/SpringHateoasSpringDataRestApplication.java create mode 100644 spring-hateoas-and-spring-data-rest/src/main/resources/application.yml create mode 100644 spring-hateoas-and-spring-data-rest/src/test/java/org/springframework/hateoas/examples/OrderIntegrationTest.java 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"))); + } + +}