diff --git a/README.md b/README.md index 8187814..d1b7411 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,14 @@ The project provides examples of Spring Security integration. The web service e The project demonstrates how to use Spring Profiles to activate (or deactivate) application components and configuration. The profiles illustrated are: batch, hsqldb, and mysql. #### Unit Tests -The project contains unit test examples for standard components such as business services or batch beans and examples for the web service endpoints using Mock objects. +The project contains unit test examples for standard components such as business services or batch beans and examples for the web service endpoints using Mock objects. Perform complete end-to-end testing with Spring MVC mocking or leverage Mockito to stub or spy business components. #### Actuator Monitoring and Management The project illustrates the use of Spring Boot Actuator for application monitoring and management. The application demonstrates the recording of custom metrics. Also, custom Maven project attributes are incorporated into the Actuator info endpoint. +#### API Documentation Generator +The project includes [Springfox](http://springfox.github.io/springfox/) Swagger integration to automatically generate API docs for the RESTful web service endpoints. This feature may be activated using the *"docs"* Spring profile. + ## Languages This project is authored in Java. diff --git a/pom.xml b/pom.xml index 56e0c6e..fc65eda 100644 --- a/pom.xml +++ b/pom.xml @@ -5,21 +5,30 @@ com.leanstacks skeleton-ws-spring-boot - 1.0.1 + 1.2.0 Web Services Project Skeleton Skeleton project for RESTful web services using Spring Boot. org.springframework.boot spring-boot-starter-parent - 1.2.2.RELEASE + 1.2.3.RELEASE UTF-8 1.7 18.0 + 2.0.0-SNAPSHOT + + + + jcenter-snapshots + jcenter + http://oss.jfrog.org/artifactory/oss-snapshot-local/ + + @@ -61,6 +70,18 @@ ${guava.version} + + + io.springfox + springfox-swagger2 + ${swagger.version} + + + io.springfox + springfox-swagger-ui + ${swagger.version} + + org.springframework.boot diff --git a/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java b/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java new file mode 100644 index 0000000..cd6777a --- /dev/null +++ b/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java @@ -0,0 +1,49 @@ +package com.leanstacks.ws; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spring.web.plugins.Docket; +import springfox.documentation.swagger2.annotations.EnableSwagger2; + +import com.google.common.base.Predicate; + +/** + * The ApiDocsConfiguration class provides configuration beans for the Swagger + * API documentation generator. + * + * @author Matt Warman + */ +@Profile("docs") +@Configuration +@EnableSwagger2 +public class ApiDocsConfiguration { + + /** + * Create a Docket class to be used by Springfox's Swagger API Documentation + * framework. See http://springfox.github.io/springfox/ for more + * information. + * @return A Docket instance. + */ + @Bean + public Docket docket() { + Predicate paths = PathSelectors.ant("/api/**"); + + ApiInfo apiInfo = new ApiInfoBuilder() + .title("Project Skeleton for Spring Boot Web Services") + .description( + "The Spring Boot web services starter project provides a foundation to rapidly construct a RESTful web services application.") + .contact("LeanStacks.com").version("1.2.0").build(); + + Docket docket = new Docket(DocumentationType.SWAGGER_2) + .apiInfo(apiInfo).select().paths(paths).build(); + + return docket; + } + +} diff --git a/src/main/java/com/leanstacks/ws/SecurityConfiguration.java b/src/main/java/com/leanstacks/ws/SecurityConfiguration.java index 5a0ab69..7e11929 100644 --- a/src/main/java/com/leanstacks/ws/SecurityConfiguration.java +++ b/src/main/java/com/leanstacks/ws/SecurityConfiguration.java @@ -3,6 +3,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -22,7 +24,7 @@ */ @Configuration @EnableWebSecurity -public class SecurityConfiguration extends WebSecurityConfigurerAdapter { +public class SecurityConfiguration { @Autowired private AccountAuthenticationProvider accountAuthenticationProvider; @@ -55,21 +57,88 @@ public void configureGlobal(AuthenticationManagerBuilder auth) auth.authenticationProvider(accountAuthenticationProvider); } + + /** + * This inner class configures the WebSecurityConfigurerAdapter instance for + * the web service API context paths. + * + * @author Matt Warman + */ + @Configuration + @Order(1) + public static class ApiWebSecurityConfigurerAdapter extends + WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + + http + .csrf().disable() + .antMatcher("/api/**") + .authorizeRequests() + .anyRequest().hasRole("USER") + .and() + .httpBasic() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + } + + } + + /** + * This inner class configures the WebSecurityConfigurerAdapter instance for + * the Spring Actuator web service context paths. + * + * @author Matt Warman + */ + @Configuration + @Order(2) + public static class ActuatorWebSecurityConfigurerAdapter extends + WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + + http + .csrf().disable() + .antMatcher("/actuators/**") + .authorizeRequests() + .anyRequest().hasRole("SYSADMIN") + .and() + .httpBasic() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + } + + } + + /** + * This inner class configures the WebSecurityConfigurerAdapter instance for + * any remaining context paths not handled by other adapters. + * + * @author Matt Warman + */ + @Profile("docs") + @Configuration + public static class FormLoginWebSecurityConfigurerAdapter extends + WebSecurityConfigurerAdapter { - @Override - protected void configure(HttpSecurity http) throws Exception { - - http - .csrf().disable() - .authorizeRequests() - .antMatchers("/api/**").hasRole("USER") - .antMatchers("/actuators/**").hasRole("SYSADMIN") - .anyRequest().authenticated() - .and() - .httpBasic() - .and() - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); - + @Override + protected void configure(HttpSecurity http) throws Exception { + + http + .csrf().disable() + .authorizeRequests() + .anyRequest().authenticated() + .and() + .formLogin(); + + } + } } diff --git a/src/main/java/com/leanstacks/ws/web/api/GreetingController.java b/src/main/java/com/leanstacks/ws/web/api/GreetingController.java index 418ad1f..8b3608e 100644 --- a/src/main/java/com/leanstacks/ws/web/api/GreetingController.java +++ b/src/main/java/com/leanstacks/ws/web/api/GreetingController.java @@ -51,7 +51,7 @@ public class GreetingController extends BaseController { value = "/api/greetings", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> getGreetings() throws Exception { + public ResponseEntity> getGreetings() { logger.info("> getGreetings"); Collection greetings = greetingService.findAll(); @@ -80,8 +80,7 @@ public ResponseEntity> getGreetings() throws Exception { value = "/api/greetings/{id}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getGreeting(@PathVariable Long id) - throws Exception { + public ResponseEntity getGreeting(@PathVariable Long id) { logger.info("> getGreeting"); Greeting greeting = greetingService.findOne(id); @@ -117,7 +116,7 @@ public ResponseEntity getGreeting(@PathVariable Long id) consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity createGreeting( - @RequestBody Greeting greeting) throws Exception { + @RequestBody Greeting greeting) { logger.info("> createGreeting"); Greeting savedGreeting = greetingService.create(greeting); @@ -152,7 +151,7 @@ public ResponseEntity createGreeting( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity updateGreeting( - @RequestBody Greeting greeting) throws Exception { + @RequestBody Greeting greeting) { logger.info("> updateGreeting"); Greeting updatedGreeting = greetingService.update(greeting); diff --git a/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java b/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java new file mode 100644 index 0000000..9ffa8f0 --- /dev/null +++ b/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java @@ -0,0 +1,28 @@ +package com.leanstacks.ws.web.docs; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * The ApiDocsController is a Spring MVC web controller class which serves the + * Swagger user interface HTML page. + * + * @author Matt Warman + */ +@Profile("docs") +@Controller +public class ApiDocsController { + + /** + * Request handler to serve the Swagger user interface HTML page configured + * to the mapped context path. + * + * @return A String name of the Swagger user interface HTML page name. + */ + @RequestMapping("/docs") + public String getSwaggerApiDocsPage() { + return "swagger-ui.html"; + } + +} diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index d12adc3..c6fcfaa 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -4,6 +4,7 @@ ## # Profile Configuration +# profiles: hsqldb, mysql, batch, docs ## spring.profiles.active=hsqldb,batch diff --git a/src/test/java/com/leanstacks/ws/AbstractControllerTest.java b/src/test/java/com/leanstacks/ws/AbstractControllerTest.java index 87da316..3e9fd10 100644 --- a/src/test/java/com/leanstacks/ws/AbstractControllerTest.java +++ b/src/test/java/com/leanstacks/ws/AbstractControllerTest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.leanstacks.ws.web.api.BaseController; /** * This class extends the functionality of AbstractTest. AbstractControllerTest @@ -38,6 +39,16 @@ protected void setUp() { mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); } + /** + * Prepares the test class for execution of web tests. Builds a MockMvc + * instance using standalone configuration facilitating the injection of + * Mockito resources into the controller class. + * @param controller A controller object to be tested. + */ + protected void setUp(BaseController controller) { + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + /** * Maps an Object into a JSON String. Uses a Jackson ObjectMapper. * @param obj The Object to map. diff --git a/src/test/java/com/leanstacks/ws/web/api/GreetingControllerMocksTest.java b/src/test/java/com/leanstacks/ws/web/api/GreetingControllerMocksTest.java new file mode 100644 index 0000000..bcb1a8d --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/GreetingControllerMocksTest.java @@ -0,0 +1,290 @@ +package com.leanstacks.ws.web.api; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collection; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import com.leanstacks.ws.AbstractControllerTest; +import com.leanstacks.ws.model.Greeting; +import com.leanstacks.ws.service.EmailService; +import com.leanstacks.ws.service.GreetingService; + +/** + * Unit tests for the GreetingController using Mockito mocks and spies. + * + * These tests utilize the Mockito framework objects to simulate interaction + * with back-end components. The controller methods are invoked directly + * bypassing the Spring MVC mappings. Back-end components are mocked and + * injected into the controller. Mockito spies and verifications are performed + * ensuring controller behaviors. + * + * @author Matt Warman + */ +@Transactional +public class GreetingControllerMocksTest extends AbstractControllerTest { + + /** + * A mocked GreetingService + */ + @Mock + private GreetingService greetingService; + + /** + * A mocked EmailService + */ + @Mock + private EmailService emailService; + + /** + * A GreetingController instance with @Mock components injected + * into it. + */ + @InjectMocks + private GreetingController greetingController; + + /** + * Setup each test method. Initialize Mockito mock and spy objects. Scan for + * Mockito annotations. + */ + @Before + public void setUp() { + // Initialize Mockito annotated components + MockitoAnnotations.initMocks(this); + // Prepare the Spring MVC Mock components for standalone testing + setUp(greetingController); + } + + @Test + public void testGetGreetings() throws Exception { + + // Create some test data + Collection list = getEntityListStubData(); + + // Stub the GreetingService.findAll method return value + when(greetingService.findAll()).thenReturn(list); + + // Perform the behavior being tested + String uri = "/api/greetings"; + + MvcResult result = mvc.perform( + MockMvcRequestBuilders.get(uri).accept( + MediaType.APPLICATION_JSON)).andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.findAll method was invoked once + verify(greetingService, times(1)).findAll(); + + // Perform standard JUnit assertions on the response + Assert.assertEquals("failure - expected HTTP status 200", 200, status); + Assert.assertTrue( + "failure - expected HTTP response body to have a value", + content.trim().length() > 0); + + } + + @Test + public void testGetGreeting() throws Exception { + + // Create some test data + Long id = new Long(1); + Greeting entity = getEntityStubData(); + + // Stub the GreetingService.findOne method return value + when(greetingService.findOne(id)).thenReturn(entity); + + // Perform the behavior being tested + String uri = "/api/greetings/{id}"; + + MvcResult result = mvc.perform( + MockMvcRequestBuilders.get(uri, id).accept( + MediaType.APPLICATION_JSON)).andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.findOne method was invoked once + verify(greetingService, times(1)).findOne(id); + + // Perform standard JUnit assertions on the test results + Assert.assertEquals("failure - expected HTTP status 200", 200, status); + Assert.assertTrue( + "failure - expected HTTP response body to have a value", + content.trim().length() > 0); + } + + @Test + public void testGetGreetingNotFound() throws Exception { + + // Create some test data + Long id = Long.MAX_VALUE; + + // Stub the GreetingService.findOne method return value + when(greetingService.findOne(id)).thenReturn(null); + + // Perform the behavior being tested + String uri = "/api/greetings/{id}"; + + MvcResult result = mvc.perform( + MockMvcRequestBuilders.get(uri, id).accept( + MediaType.APPLICATION_JSON)).andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.findOne method was invoked once + verify(greetingService, times(1)).findOne(id); + + // Perform standard JUnit assertions on the test results + Assert.assertEquals("failure - expected HTTP status 404", 404, status); + Assert.assertTrue("failure - expected HTTP response body to be empty", + content.trim().length() == 0); + + } + + @Test + public void testCreateGreeting() throws Exception { + + // Create some test data + Greeting entity = getEntityStubData(); + + // Stub the GreetingService.create method return value + when(greetingService.create(any(Greeting.class))).thenReturn(entity); + + // Perform the behavior being tested + String uri = "/api/greetings"; + String inputJson = super.mapToJson(entity); + + MvcResult result = mvc.perform( + MockMvcRequestBuilders.post(uri) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).content(inputJson)) + .andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.create method was invoked once + verify(greetingService, times(1)).create(any(Greeting.class)); + + // Perform standard JUnit assertions on the test results + Assert.assertEquals("failure - expected HTTP status 201", 201, status); + Assert.assertTrue( + "failure - expected HTTP response body to have a value", + content.trim().length() > 0); + + Greeting createdEntity = super.mapFromJson(content, Greeting.class); + + Assert.assertNotNull("failure - expected entity not null", + createdEntity); + Assert.assertNotNull("failure - expected id attribute not null", + createdEntity.getId()); + Assert.assertEquals("failure - expected text attribute match", + entity.getText(), createdEntity.getText()); + } + + @Test + public void testUpdateGreeting() throws Exception { + + // Create some test data + Greeting entity = getEntityStubData(); + entity.setText(entity.getText() + " test"); + Long id = new Long(1); + + // Stub the GreetingService.update method return value + when(greetingService.update(any(Greeting.class))).thenReturn(entity); + + // Perform the behavior being tested + String uri = "/api/greetings/{id}"; + String inputJson = super.mapToJson(entity); + + MvcResult result = mvc.perform( + MockMvcRequestBuilders.put(uri, id) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).content(inputJson)) + .andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.update method was invoked once + verify(greetingService, times(1)).update(any(Greeting.class)); + + // Perform standard JUnit assertions on the test results + Assert.assertEquals("failure - expected HTTP status 200", 200, status); + Assert.assertTrue( + "failure - expected HTTP response body to have a value", + content.trim().length() > 0); + + Greeting updatedEntity = super.mapFromJson(content, Greeting.class); + + Assert.assertNotNull("failure - expected entity not null", + updatedEntity); + Assert.assertEquals("failure - expected id attribute unchanged", + entity.getId(), updatedEntity.getId()); + Assert.assertEquals("failure - expected text attribute match", + entity.getText(), updatedEntity.getText()); + + } + + @Test + public void testDeleteGreeting() throws Exception { + + // Create some test data + Long id = new Long(1); + + // Perform the behavior being tested + String uri = "/api/greetings/{id}"; + + MvcResult result = mvc.perform(MockMvcRequestBuilders.delete(uri, id)) + .andReturn(); + + // Extract the response status and body + String content = result.getResponse().getContentAsString(); + int status = result.getResponse().getStatus(); + + // Verify the GreetingService.delete method was invoked once + verify(greetingService, times(1)).delete(id); + + // Perform standard JUnit assertions on the test results + Assert.assertEquals("failure - expected HTTP status 204", 204, status); + Assert.assertTrue("failure - expected HTTP response body to be empty", + content.trim().length() == 0); + + } + + private Collection getEntityListStubData() { + Collection list = new ArrayList(); + list.add(getEntityStubData()); + return list; + } + + private Greeting getEntityStubData() { + Greeting entity = new Greeting(); + entity.setId(1L); + entity.setText("hello"); + return entity; + } + +} diff --git a/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java b/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java index f08bdac..53a7b70 100644 --- a/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java +++ b/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java @@ -14,7 +14,12 @@ import com.leanstacks.ws.service.GreetingService; /** - * Unit tests for the GreetingController. + * Unit tests for the GreetingController using Spring MVC Mocks. + * + * These tests utilize the Spring MVC Mock objects to simulate sending actual + * HTTP requests to the Controller component. This test ensures that the + * RequestMappings are configured correctly. Also, these tests ensure that the + * request and response bodies are serialized as expected. * * @author Matt Warman */