Now that you have added rich views and a basket, as on your life preserver below, its time to add the ability to place an order.
For Yummy Noodle Bar to accept the orders that a user is making, it needs to know where to send it. Your users will need to give their name and address.
To do this, you must:
- Add a checkout URL - "/checkout"
- Show an HTML form on GET
- Process the form information on POST
- Convert the Basket into an Order and send it to the core.
You will continue working within the Web domain, first created in step 2.
The Basket you created in the last section will contain all the items that a user wants to order. When they want to place their Order, you need to also collect sufficient information from the customer to deliver their Order.
To do this you will create a new Controller, and have that Controller accept a Command Object.
A command Object is a bean that is used to model an HTTP request. It does this by automatically mapping request parameters onto the properties of the bean. These properties can then be tested using Validation.
Java has a standard Validation API, which you will need to include in the dependencies section of build.gradle
.
build.gradle
compile 'javax.validation:validation-api:1.1.0.Final'
compile 'org.hibernate:hibernate-validator:5.0.1.Final'
Since the Validation specification only defines an API, you need to include an implementation of that API as well. Above, you have included Hibernate Validator.
As you should expect, you will first write a test to describe the features you want to implement. Once those tests are ready, you can then safely implement the features themselves.
Add this test into your project.
src/test/java/com/yummynoodlebar/web/controller/CheckoutIntegrationTest.java
package com.yummynoodlebar.web.controller;
import static com.yummynoodlebar.web.controller.fixture.WebDataFixture.newOrder;
import static com.yummynoodlebar.web.controller.fixture.WebDataFixture.standardWebMenuItem;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertThat;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import java.util.UUID;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Matchers;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import com.yummynoodlebar.core.services.OrderService;
import com.yummynoodlebar.events.orders.CreateOrderEvent;
import com.yummynoodlebar.web.domain.Basket;
public class CheckoutIntegrationTest {
private static final String POST_CODE = "90210";
private static final String ADDRESS1 = "Where they live";
private static final String CUSTOMER_NAME = "Customer Name";
private static final String CHECKOUT_VIEW = "/WEB-INF/views/checkout.html";
MockMvc mockMvc;
@InjectMocks
CheckoutController controller;
@Mock
OrderService orderService;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
controller.setBasket(new Basket());
mockMvc = standaloneSetup(controller).setViewResolvers(viewResolver())
.build();
}
private InternalResourceViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/views");
viewResolver.setSuffix(".html");
return viewResolver;
}
@Test
public void thatBasketIsPopulated() throws Exception {
mockMvc.perform(get("/checkout")).andExpect(
model().attributeExists("basket"));
}
@Test
public void thatCheckoutViewIsCorrect() throws Exception {
mockMvc.perform(get("/checkout"))
.andExpect(forwardedUrl(CHECKOUT_VIEW));
}
@Test
public void thatRedirectsToOrderOnSuccess() throws Exception {
UUID id = UUID.randomUUID();
when(orderService.createOrder(any(CreateOrderEvent.class))).thenReturn(newOrder(id));
mockMvc.perform(
post("/checkout").param("name", CUSTOMER_NAME)
.param("address1", ADDRESS1)
.param("postcode", POST_CODE))
.andExpect(status().isMovedTemporarily())
.andExpect(redirectedUrl("/order/" + id.toString()));
}
@Test
public void thatSendsCorrectOrderEventOnSuccess() throws Exception {
UUID id = UUID.randomUUID();
when(orderService.createOrder(any(CreateOrderEvent.class))).thenReturn(newOrder(id));
mockMvc.perform(post("/checkout")
.param("name", CUSTOMER_NAME)
.param("address1", ADDRESS1)
.param("postcode", POST_CODE))
.andDo(print());
//@formatter:off
verify(orderService).createOrder(Matchers.<CreateOrderEvent>argThat(
allOf(
org.hamcrest.Matchers.<CreateOrderEvent>hasProperty("details",
hasProperty("dateTimeOfSubmission", notNullValue())),
org.hamcrest.Matchers.<CreateOrderEvent>hasProperty("details",
hasProperty("name", equalTo(CUSTOMER_NAME))),
org.hamcrest.Matchers.<CreateOrderEvent>hasProperty("details",
hasProperty("address1", equalTo(ADDRESS1))),
org.hamcrest.Matchers.<CreateOrderEvent>hasProperty("details",
hasProperty("postcode", equalTo(POST_CODE)))
)));
//@formatter:on
}
@Test
public void thatBasketIsEmptyOnSuccess() throws Exception {
UUID id = UUID.randomUUID();
when(orderService.createOrder(any(CreateOrderEvent.class))).thenReturn(newOrder(id));
controller.getBasket().add(standardWebMenuItem());
mockMvc.perform(
post("/checkout").param("name", CUSTOMER_NAME)
.param("address1", ADDRESS1)
.param("postcode", POST_CODE));
assertThat(controller.getBasket().getItems(), is(empty()));
}
@Test
public void thatReturnsToCheckoutIfValidationFail() throws Exception {
UUID id = UUID.randomUUID();
when(orderService.createOrder(any(CreateOrderEvent.class))).thenReturn(
newOrder(id));
mockMvc.perform(post("/checkout").param("postcode", POST_CODE))
.andExpect(forwardedUrl(CHECKOUT_VIEW));
}
}
src/test/java/com/yummynoodlebar/web/controller/CheckoutIntegrationTest.java
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
controller.setBasket(new Basket());
mockMvc = standaloneSetup(controller).setViewResolvers(viewResolver())
.build();
}
private InternalResourceViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/views");
viewResolver.setSuffix(".html");
return viewResolver;
}
Following that are tests that check:
- The basket is correctly added to the model.
- The view forward is correct.
- The checkout controller will redirect to the url
/order
if the POST is complete and correct. - The checkout controller will forward back to the url
/checkout
if the POST is incomplete.
This breaking up of tests follows the Clean Code principles laid out by Rob C. Martin.
The applicable principles here are that each test method is checking a single concept in the functionality of the URL. The number of assertions involved in each test is irrelevant so long as they are all part of building confidence in a single function of the system.
The first thing you need to do is introduce the new concepts you need into the system. The first is the command object.
When you submit a POST request to /checkout, it will contain a set of POST variables in the request that will include information about a user.
You could parse these variables yourself and check their contents according to whatever rules you want to apply. This happens so often, however, that Spring supplies a lot of functionality to ease your implementation.
The Command Object is a class that Spring will map the POST variables onto, parsing them into the given types on the class. For example, if you have an int
property on the Command Object, Spring will take the textual value supplied in the request and attempt to parse an int
out of it. This process of automatic parsing and conversion is known as Binding, and you can find out more in the reference documentation
Command Objects are also the ideal place for validation.
Create a new entity class, CustomerInfo
, like so.
src/main/java/com/yummynoodlebar/web/domain/CustomerInfo.java
package com.yummynoodlebar.web.domain;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
public class CustomerInfo implements Serializable {
@NotNull
@NotEmpty
private String name;
@NotNull
@NotEmpty
private String address1;
@NotNull
@NotEmpty
private String postcode;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress1() {
return address1;
}
public void setAddress1(String address1) {
this.address1 = address1;
}
public String getPostcode() {
return postcode;
}
public void setPostcode(String postcode) {
this.postcode = postcode;
}
}
This entity concept will only exist in the Web Domain. LP
In it you have added validation annotations @NotNull
and @NotEmpty
from the standard validation API and a Hibernate Validator extension, respectively.
These do not do anything by themselves, they need to be interpreted by some Validator class. This is done on request in a Spring MVC Controller, which you will implement in the next section.
Now that the Command Object is ready to represent the incoming POST request, you can implement the Controller.
Create a new class CheckoutController
, like so.
src/main/java/com/yummynoodlebar/web/controller/CheckoutController.java
package com.yummynoodlebar.web.controller;
import com.yummynoodlebar.core.services.OrderService;
import com.yummynoodlebar.events.orders.CreateOrderEvent;
import com.yummynoodlebar.events.orders.OrderCreatedEvent;
import com.yummynoodlebar.events.orders.OrderDetails;
import com.yummynoodlebar.web.domain.Basket;
import com.yummynoodlebar.web.domain.CustomerInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
import java.util.UUID;
@Controller
@RequestMapping("/checkout")
public class CheckoutController {
private static final Logger LOG = LoggerFactory
.getLogger(BasketCommandController.class);
@Autowired
private Basket basket;
@Autowired
private OrderService orderService;
@RequestMapping(method = RequestMethod.GET)
public String checkout() {
return "/checkout";
}
@RequestMapping(method = RequestMethod.POST)
public String doCheckout(@Valid @ModelAttribute("customerInfo") CustomerInfo customer, BindingResult result, RedirectAttributes redirectAttrs) {
if (result.hasErrors()) {
// errors in the form
// show the checkout form again
return "/checkout";
}
LOG.debug("No errors, continue with processing for Customer {}:",
customer.getName());
OrderDetails order = basket
.createOrderDetailsWithCustomerInfo(customer);
OrderCreatedEvent event = orderService
.createOrder(new CreateOrderEvent(order));
UUID key = event.getNewOrderKey();
redirectAttrs.addFlashAttribute("message",
"Your order has been accepted!");
basket.clear();
LOG.debug("Basket now has {} items", basket.getSize());
return "redirect:/order/" + key.toString();
}
@ModelAttribute("customerInfo")
private CustomerInfo getCustomerInfo() {
return new CustomerInfo();
}
@ModelAttribute("basket")
public Basket getBasket() {
return basket;
}
public void setBasket(Basket basket) {
this.basket = basket;
}
}
The controller provides two implementations for the same URL /checkout
.
If you access the URL with a HTTP GET, you will provided with the /checkout view (which will be resolved to the checkout.html).
If you access the URL with a HTTP POST, then the Controller expects that a form has been submitted. To process this form, it uses the Command Object customerInfo
, of type CustomerInfo
.
You will notice the @ModelAttribute
annotation on the customerInfo
parameter, and a matching method below
src/main/java/com/yummynoodlebar/web/controller/CheckoutController.java
@ModelAttribute("customerInfo")
private CustomerInfo getCustomerInfo() {
return new CustomerInfo();
}
Together, these declare the CustomerInfo class to be a Command Object. When the page is rendered for the first time on a GET /checkout
, the method getCustomerInfo
is called to generate the 'customerInfo' property in the model. You could pre-populate this is you wanted to in the getCustomerInfo
method. This property is then available in the model for the View to use during rendering, which you will see in the next section.
Using CustomerInfo as a parameter means that Spring will perform Binding of the request parameters against it. If the binding did not complete successfully, then the result is stored in the BindingResult
parameter.
In this case, the method immediately re-renders the checkout view. The CustomerInfo instance, that did not bind correctly, will be available in the view to render, and you will see what support you have in the next section.
You will also notice a @Valid
annotation on the CustomerInfo
, this indicates to Spring that this instance should be validated. This will use the annotations you added earlier to check the fields. If the fields all pass the validation rules you have specified, then the bean is deemed to be valid, if not then it is invalid and the binding will fail.
Now that the checkout URL is available and tested, its time to add the View to show the form to collect the customer information you need.
This view needs to populate the CustomerInfo bean
src/main/webapp/WEB-INF/views/checkout.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content=""/>
<meta name="author" content="Simplicity Itself"/>
<title>Yummy Noodle Bar</title>
<link href="/resources/css/bootstrap.min.css" rel="stylesheet">
</link>
<style type="text/css">
body {
padding-top: 60px;
padding-bottom: 40px;
}
.sidebar-nav {
padding: 9px 0;
}
</style>
<!-- See http://twitter.github.com/bootstrap/scaffolding.html#responsive -->
<link href="/resources/css/bootstrap-responsive.min.css" rel="stylesheet">
</link>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
<div th:include="layout :: head"></div>
<div class="container-fluid">
<div th:include="layout :: left"></div>
<div class="hero-unit span9">
<h3>Where do we deliver your Order?</h3>
<p>
<a class="btn btn-primary btn-large" href="/showBasket">Back to basket</a>
</p>
</div>
<div class="row-fluid">
<div class="span8 sidebar-nav">
<div th:if="${message}"
th:text="${message}"
id="message" class="alert alert-info">
</div>
<form method="POST" th:object="${customerInfo}">
<table>
<tr>
<td>Name:</td>
<td><input type="text" th:field="*{name}" /></td>
<td th:if="${#fields.hasErrors('name')}"><p th:errors="*{name}">Incorrect Name</p></td>
</tr>
<tr>
<td>Address:</td>
<td><input type="text" th:field="*{address1}" /></td>
<td th:if="${#fields.hasErrors('address1')}"><p th:errors="*{address1}">Incorrect Address</p></td>
</tr>
<tr>
<td>Postal Code:</td>
<td><input type="text" th:field="*{postcode}" /></td>
<td th:if="${#fields.hasErrors('postcode')}"><p th:errors="*{postcode}">Postcode Error</p></td>
</tr>
<tr>
<td colspan="3">
<input type="submit" value="Place order" />
</td>
</tr>
</table>
</form>
<div th:include="layout :: foot"></div>
</div>
</div>
</div>
</body>
</html>
You are aiming for a view that looks like this
If you open the checkout html view using a browser, you will get most of the page rendered, minus the templates brought in by Thymeleaf
Once the user has successfully checked out and placed their Order, you need to show them a screen that displays the Order they have placed and the current status of it. This allows the kitchen to update the status of the Order and the user to see that new status.
This is a relatively straightforward addition to the code you've written before. As with the CheckoutController, you first need to write a test, like so.
src/test/java/com/yummynoodlebar/web/controller/OrderStatusIntegrationTest.java
package com.yummynoodlebar.web.controller;
import static com.yummynoodlebar.web.controller.fixture.WebDataFixture.orderDetailsEvent;
import static com.yummynoodlebar.web.controller.fixture.WebDataFixture.orderStatusEvent;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasProperty;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import java.util.UUID;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Matchers;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
import com.yummynoodlebar.core.services.OrderService;
import com.yummynoodlebar.events.orders.RequestOrderDetailsEvent;
import com.yummynoodlebar.events.orders.RequestOrderStatusEvent;
import com.yummynoodlebar.web.controller.fixture.WebDataFixture;
public class OrderStatusIntegrationTest {
private static final String ORDER_VIEW = "/WEB-INF/views/order.html";
private static UUID uuid;
MockMvc mockMvc;
@InjectMocks
OrderStatusController controller;
@Mock
OrderService orderService;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
mockMvc = standaloneSetup(controller).setViewResolvers(viewResolver())
.build();
uuid = UUID.randomUUID();
}
private InternalResourceViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/views");
viewResolver.setSuffix(".html");
return viewResolver;
}
@Test
public void thatOrderViewIsForwardedTo() throws Exception {
when(orderService.requestOrderDetails(any(RequestOrderDetailsEvent.class))).thenReturn(orderDetailsEvent(uuid));
when(orderService.requestOrderStatus(any(RequestOrderStatusEvent.class))).thenReturn(orderStatusEvent(uuid));
mockMvc.perform(get("/order/" + uuid))
.andExpect(status().isOk())
.andExpect(forwardedUrl(ORDER_VIEW));
}
@Test
public void thatOrderStatusIsPutInModel() throws Exception {
when(orderService.requestOrderDetails(any(RequestOrderDetailsEvent.class))).thenReturn(orderDetailsEvent(uuid));
when(orderService.requestOrderStatus(any(RequestOrderStatusEvent.class))).thenReturn(orderStatusEvent(uuid));
mockMvc.perform(get("/order/" + uuid))
.andExpect(model().attributeExists("orderStatus"))
.andExpect(model().attribute("orderStatus", hasProperty("name", equalTo(WebDataFixture.CUSTOMER_NAME))))
.andExpect(model().attribute("orderStatus", hasProperty("status", equalTo(WebDataFixture.STATUS_RECEIVED))));
verify(orderService).requestOrderDetails(Matchers.<RequestOrderDetailsEvent>argThat(
org.hamcrest.Matchers.<RequestOrderDetailsEvent>hasProperty("key", equalTo(uuid))));
verify(orderService).requestOrderStatus(any(RequestOrderStatusEvent.class));
}
}
Now that you have a test, you need to implement the controller, which will look something like this
src/main/java/com/yummynoodlebar/web/controller/OrderStatusController.java
package com.yummynoodlebar.web.controller;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import com.yummynoodlebar.core.services.OrderService;
import com.yummynoodlebar.events.orders.OrderDetailsEvent;
import com.yummynoodlebar.events.orders.OrderStatusEvent;
import com.yummynoodlebar.events.orders.RequestOrderDetailsEvent;
import com.yummynoodlebar.events.orders.RequestOrderStatusEvent;
import com.yummynoodlebar.web.domain.OrderStatus;
@Controller
@RequestMapping("/order/{orderId}")
public class OrderStatusController {
private static final Logger LOG = LoggerFactory
.getLogger(OrderStatusController.class);
@Autowired
private OrderService orderService;
@RequestMapping(method = RequestMethod.GET)
public String orderStatus(@ModelAttribute("orderStatus") OrderStatus orderStatus) {
LOG.debug("Get order status for order id {} customer {}", orderStatus.getOrderId(), orderStatus.getName());
return "/order";
}
@ModelAttribute("orderStatus")
private OrderStatus getOrderStatus(@PathVariable("orderId") String orderId) {
OrderDetailsEvent orderDetailsEvent = orderService.requestOrderDetails(new RequestOrderDetailsEvent(UUID.fromString(orderId)));
OrderStatusEvent orderStatusEvent = orderService.requestOrderStatus(new RequestOrderStatusEvent(UUID.fromString(orderId)));
OrderStatus status = new OrderStatus();
status.setName(orderDetailsEvent.getOrderDetails().getName());
status.setOrderId(orderId);
status.setStatus(orderStatusEvent.getOrderStatus().getStatus());
return status;
}
}
Lastly, create the view for the order and its status.
src/main/webapp/WEB-INF/views/order.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content=""/>
<meta name="author" content="Simplicity Itself"/>
<title>Yummy Noodle Bar</title>
<link href="/resources/css/bootstrap.min.css" rel="stylesheet">
</link>
<style type="text/css">
body {
padding-top: 60px;
padding-bottom: 40px;
}
.sidebar-nav {
padding: 9px 0;
}
</style>
<!-- See http://twitter.github.com/bootstrap/scaffolding.html#responsive -->
<link href="/resources/css/bootstrap-responsive.min.css" rel="stylesheet">
</link>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
<div th:include="layout :: head"></div>
<div class="container-fluid">
<div class="hero-unit">
<h3>Your order is confirmed</h3>
<p>
<a class="btn btn-primary btn-large" href="/">Please can I have some more</a>
<a class="btn btn-primary btn-large"
th:href="'/order/' + ${orderStatus.orderId}">Get latest status</a>
</p>
</div>
<div class="row-fluid sidebar-nav">
<div class="span8">
<p class="text-info"><span th:text="${orderStatus.name}">, </span>Thanks for your order</p>
<p class="text-info">Your order number is <span th:text="${orderStatus.orderId}"></span></p>
<p class="text-info">The estimate for cooking is 20 minutes</p>
<p class="text-success">The status is currently <span th:text="${orderStatus.status}"></span></p>
</div>
</div>
<div th:include="layout :: foot"></div>
</div>
</body>
</html>
It should look like
You have successfully captured some user information in a form, mapped this onto a command object, validated it and combined it with the basket to create a fully functioning Order.
See the current state of your application in the following Life Preserver:
Your application is a little too open with its information, however. In the next section, you will learn how to apply security to your application and control who has access to which parts of your website, using Spring Security.