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

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