diff --git a/README.md b/README.md index e247eaf..f7a51ab 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The project contains unit and integration test examples for standard components The project illustrates the use of Spring Boot Actuator for application monitoring and management. The application demonstrates the recording of custom metrics and the creation of custom health checks. Also, custom Maven and Gradle 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. +The project includes [Spring REST Docs](https://spring.io/projects/spring-restdocs) integration to automatically generate API docs for the RESTful web service endpoints. This feature may be activated using the *"asciidoctor"* Gradle task. #### Executable Jar The Maven and Gradle builds produce a fully executable Spring Boot Jar file. The Jar file may be executed directly from the command line without the *"java -jar"* command and may be installed on servers as a Linux service. @@ -279,6 +279,26 @@ java -jar build/libs/example-1.0.0.jar --spring.profiles.active=mysql,batch ./example-1.0.0.jar --spring.profiles.active=mysql,batch ``` +#### asciidoctor + +The `asciidoctor` Gradle task performs the following workflow steps: + +* compiles Java classes to the /build directory +* copies all resources to the /build directory +* executes the unit test suites +* generates [Asciidoctor](https://asciidoctor.org/) snippets in the /build/generated-snippets directory +* generates HTML API Docs in the /build/asciidoc/html5 directory + +The `asciidoctor` Gradle task generates API documentation using [Spring REST Docs](https://spring.io/projects/spring-restdocs). + +To execute the `asciidoctor` Gradle task, type the following command at a terminal prompt in the project base directory. + +``` +./gradlew clean asciidoctor +``` + +The API documentation is placed in the /build/asciidoc/html5 directory and the root document is named `index.html`. + #### encodePassword The `encodePassword` Gradle task executes the `BCryptPasswordEncoderUtil` utility class to encode password values which may be included in the sample database scripts. The clear text password values are passed as a Gradle `-P` property arguments on the command line. diff --git a/build.gradle b/build.gradle index 081a996..55fe8fe 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id 'org.springframework.boot' version '2.1.1.RELEASE' + id 'org.asciidoctor.convert' version '1.5.3' } apply plugin: 'io.spring.dependency-management' @@ -12,14 +13,15 @@ apply plugin: 'project-report' apply plugin: 'build-dashboard' ext { + snippetsDir = file('build/generated-snippets') + jacocoVersion = '0.8.2' checkstyleVersion = '8.14' pmdVersion = '6.9.0' - swaggerVersion = '2.7.0' } group = 'com.leanstacks' -version = '2.2.0' +version = '2.3.0' sourceCompatibility = 11 targetCompatibility = 11 @@ -37,14 +39,16 @@ dependencies { compile group: 'com.github.ben-manes.caffeine', name: 'caffeine' compile group: 'org.liquibase', name: 'liquibase-core' - compile group: 'io.springfox', name: 'springfox-swagger2', version: swaggerVersion - compile group: 'io.springfox', name: 'springfox-swagger-ui', version: swaggerVersion runtime group: 'org.hsqldb', name: 'hsqldb' runtime group: 'mysql', name: 'mysql-connector-java' testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test' testCompile group: 'org.springframework.security', name: 'spring-security-test' + testCompile group: 'org.springframework.restdocs', name: 'spring-restdocs-mockmvc' + testCompile group: 'com.google.guava', name: 'guava', version: '27.0.1-jre' + + asciidoctor group: 'org.springframework.restdocs', name: 'spring-restdocs-asciidoctor' } defaultTasks 'clean', 'build' @@ -81,7 +85,15 @@ jacocoTestReport { } } -test.finalizedBy jacocoTestReport +test { + outputs.dir snippetsDir + finalizedBy jacocoTestReport +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} checkstyle { toolVersion = checkstyleVersion @@ -90,7 +102,8 @@ checkstyle { pmd { toolVersion = pmdVersion - ruleSetConfig = rootProject.resources.text.fromFile('etc/pmd/ruleset.xml') + ruleSets = [] + ruleSetFiles = files('etc/pmd/ruleset.xml') ignoreFailures = true } diff --git a/etc/pmd/ruleset.xml b/etc/pmd/ruleset.xml index 3af8d85..0b5750e 100644 --- a/etc/pmd/ruleset.xml +++ b/etc/pmd/ruleset.xml @@ -1,78 +1,89 @@ - + xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.io/ruleset_2_0_0.xsd"> - This is the LeanStacks Official PMD ruleset. - + This is the LeanStacks Official PMD ruleset. + - - + - - - - - - - - - + + - + + - + - - - - + + + + - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - + + + + + - - + + + + + - - + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0eab6b9..abc307e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ com.leanstacks skeleton-ws-spring-boot - 2.2.0 + 2.3.0 Spring Boot Starter Project Starter application stack for RESTful web services using Spring Boot. @@ -18,7 +18,6 @@ UTF-8 11 - 2.7.0 @@ -66,18 +65,6 @@ - - - io.springfox - springfox-swagger2 - ${swagger.version} - - - io.springfox - springfox-swagger-ui - ${swagger.version} - - org.springframework.boot @@ -89,6 +76,18 @@ spring-security-test test + + com.google.guava + guava + 27.0.1-jre + test + + + + + org.springframework.restdocs + spring-restdocs-mockmvc + diff --git a/src/docs/asciidoc/_includes/http-response-error.adoc b/src/docs/asciidoc/_includes/http-response-error.adoc new file mode 100644 index 0000000..bba7511 --- /dev/null +++ b/src/docs/asciidoc/_includes/http-response-error.adoc @@ -0,0 +1,16 @@ +[source,http,options="nowrap"] +---- +HTTP/1.1 404 +Content-Length: 213 +Content-Type: application/json;charset=UTF-8 + +{ + "timestamp": "2018-12-12T13:16:11.771539Z", + "method": "GET", + "path": "/api/...", + "status": 404, + "statusText": "Not Found", + "exceptionClass": "java.util.NoSuchElementException", + "exceptionMessage": "No value present" +} +---- \ No newline at end of file diff --git a/src/docs/asciidoc/_includes/response-fields-error.adoc b/src/docs/asciidoc/_includes/response-fields-error.adoc new file mode 100644 index 0000000..28f6c68 --- /dev/null +++ b/src/docs/asciidoc/_includes/response-fields-error.adoc @@ -0,0 +1,32 @@ +|=== +|Path|Type|Description + +|`+timestamp+` +|`+String+` +|The time the error occurred. + +|`+method+` +|`+String+` +|The HTTP method. + +|`+path+` +|`+String+` +|The request context path. + +|`+status+` +|`+Number+` +|The response HTTP status code. + +|`+statusText+` +|`+String+` +|The response HTTP status text. + +|`+exceptionClass+` +|`+String+` +|The exception class. + +|`+exceptionMessage+` +|`+String+` +|The exception message. + +|=== \ No newline at end of file diff --git a/src/docs/asciidoc/create-greeting.adoc b/src/docs/asciidoc/create-greeting.adoc new file mode 100644 index 0000000..689070f --- /dev/null +++ b/src/docs/asciidoc/create-greeting.adoc @@ -0,0 +1,40 @@ += API Endpoint - Create Greeting +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Create a new greeting + +=== POST /api/greetings + +Create a new greeting. + +=== Request Body Parameters + +include::{snippets}/create-greeting/request-fields.adoc[] + +=== Response Body Parameters + +include::{snippets}/create-greeting/response-fields.adoc[] + +=== Example Request + +Using cURL: + +include::{snippets}/create-greeting/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/create-greeting/http-request.adoc[] + +=== Example Response + +include::{snippets}/create-greeting/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/docs/asciidoc/delete-greeting.adoc b/src/docs/asciidoc/delete-greeting.adoc new file mode 100644 index 0000000..4895c16 --- /dev/null +++ b/src/docs/asciidoc/delete-greeting.adoc @@ -0,0 +1,32 @@ += API Endpoint - Delete Greeting +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Delete a greeting + +=== DELETE /api/greetings/{id} + +Delete all information about a specific greeting. + +=== Example Request + +Using cURL: + +include::{snippets}/delete-greeting/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/delete-greeting/http-request.adoc[] + +=== Example Response + +include::{snippets}/delete-greeting/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/docs/asciidoc/get-greeting.adoc b/src/docs/asciidoc/get-greeting.adoc new file mode 100644 index 0000000..14444bd --- /dev/null +++ b/src/docs/asciidoc/get-greeting.adoc @@ -0,0 +1,40 @@ += API Endpoint - Get Greeting +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Get a specific greeting + +=== GET /api/greetings/{id} + +Get the information about a specific greeting. + +=== Path Parameters + +include::{snippets}/get-greeting/path-parameters.adoc[] + +=== Response Body Parameters + +include::{snippets}/get-greeting/response-fields.adoc[] + +=== Example Request + +Using cURL: + +include::{snippets}/get-greeting/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/get-greeting/http-request.adoc[] + +=== Example Response + +include::{snippets}/get-greeting/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/docs/asciidoc/get-greetings.adoc b/src/docs/asciidoc/get-greetings.adoc new file mode 100644 index 0000000..1d3e6db --- /dev/null +++ b/src/docs/asciidoc/get-greetings.adoc @@ -0,0 +1,36 @@ += API Endpoint - Get Greetings +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Get a list of greetings + +=== GET /api/greetings + +Get a list of all greetings. + +=== Response Body Parameters + +include::{snippets}/get-greeting/response-fields.adoc[] + +=== Example Request + +Using cURL: + +include::{snippets}/get-greetings/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/get-greetings/http-request.adoc[] + +=== Example Response + +include::{snippets}/get-greetings/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..db1f63d --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,27 @@ += Overview +:doctype: book +:icons: font +:source-highlighter: highlightjs + +Review the available resources from the LeanStacks API. Click any endpoint for additional information. + +=== Greetings + +[cols="2,4,4",options="header"] +|=== + +| HTTP method | Endpoint | Function + +| GET | <> | Get a list of greetings + +| GET | <> | Get a specific greeting + +| POST | <> | Create a greeting + +| PUT | <> | Update a greeting + +| DELETE | <> | Delete a greeting + +| POST | <> | Send a greeting + +|=== diff --git a/src/docs/asciidoc/send-greeting.adoc b/src/docs/asciidoc/send-greeting.adoc new file mode 100644 index 0000000..9a89133 --- /dev/null +++ b/src/docs/asciidoc/send-greeting.adoc @@ -0,0 +1,44 @@ += API Endpoint - Send Greeting +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Send a greeting + +=== POST /api/greetings/{id}/send + +Email a specific greeting synchronously or asynchronously. + +=== Path Parameters + +include::{snippets}/send-greeting/path-parameters.adoc[] + +=== Request Parameters + +include::{snippets}/send-greeting/request-parameters.adoc[] + +=== Response Body Parameters + +include::{snippets}/send-greeting/response-fields.adoc[] + +=== Example Request + +Using cURL: + +include::{snippets}/send-greeting/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/send-greeting/http-request.adoc[] + +=== Example Response + +include::{snippets}/send-greeting/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/docs/asciidoc/update-greeting.adoc b/src/docs/asciidoc/update-greeting.adoc new file mode 100644 index 0000000..87f875c --- /dev/null +++ b/src/docs/asciidoc/update-greeting.adoc @@ -0,0 +1,44 @@ += API Endpoint - Update Greeting +LeanStacks; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:includedir: _includes + +== Update a greeting + +=== PUT /api/greetings/{id} + +Update the information about a specific greeting. + +=== Path Parameters + +include::{snippets}/update-greeting/path-parameters.adoc[] + +=== Request Body Parameters + +include::{snippets}/update-greeting/request-fields.adoc[] + +=== Response Body Parameters + +include::{snippets}/update-greeting/response-fields.adoc[] + +=== Example Request + +Using cURL: + +include::{snippets}/update-greeting/curl-request.adoc[] + +The HTTP request: + +include::{snippets}/update-greeting/http-request.adoc[] + +=== Example Response + +include::{snippets}/update-greeting/http-response.adoc[] + +=== Example Error Response + +include::{includedir}/http-response-error.adoc[] + +include::{includedir}/response-fields-error.adoc[] diff --git a/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java b/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java deleted file mode 100644 index 096241f..0000000 --- a/src/main/java/com/leanstacks/ws/ApiDocsConfiguration.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.leanstacks.ws; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; - -import com.google.common.base.Predicate; - -import springfox.documentation.builders.ApiInfoBuilder; -import springfox.documentation.builders.PathSelectors; -import springfox.documentation.service.ApiInfo; -import springfox.documentation.service.Contact; -import springfox.documentation.spi.DocumentationType; -import springfox.documentation.spring.web.plugins.Docket; -import springfox.documentation.swagger2.annotations.EnableSwagger2; - -/** - * The ApiDocsConfiguration class provides configuration beans for the Swagger API documentation generator. - * - * @author Matt Warman - */ -@Profile("docs") -@Configuration -@EnableSwagger2 -public class ApiDocsConfiguration { - - /** - * The project version. - */ - public static final String PROJECT_VERSION = "2.2.0"; - /** - * The project contact name. - */ - public static final String PROJECT_CONTACT_NAME = "LeanStacks.com"; - /** - * The project contact URL. - */ - public static final String PROJECT_CONTACT_URL = "http://www.leanstacks.com/"; - - /** - * Create a Contact class to be used by Springfox's Swagger API Documentation framework. - * - * @return A Contact instance. - */ - private Contact contact() { - return new Contact(PROJECT_CONTACT_NAME, PROJECT_CONTACT_URL, null); - } - - /** - * Create an ApiInfo class to be used by Springfox's Swagger API Documentation framework. - * - * @return An ApiInfo instance. - */ - private ApiInfo apiInfo() { - - // @formatter:off - return 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(contact()) - .version(PROJECT_VERSION) - .build(); - // @formatter:on - - } - - /** - * 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() { - final Predicate paths = PathSelectors.ant("/api/**"); - - // @formatter:off - return new Docket(DocumentationType.SWAGGER_2) - .apiInfo(apiInfo()) - .select() - .paths(paths) - .build(); - // @formatter:on - - } - -} diff --git a/src/main/java/com/leanstacks/ws/batch/GreetingBatchBean.java b/src/main/java/com/leanstacks/ws/batch/GreetingBatchBean.java index a7de569..9c07c1c 100644 --- a/src/main/java/com/leanstacks/ws/batch/GreetingBatchBean.java +++ b/src/main/java/com/leanstacks/ws/batch/GreetingBatchBean.java @@ -30,19 +30,36 @@ public class GreetingBatchBean { */ private static final Logger logger = LoggerFactory.getLogger(GreetingBatchBean.class); + /** + * Format for printed messages. + */ private static final String MESSAGE_FORMAT = "There are {} greetings in the data store."; - // Metric Counters + /** + * Metric Counter for cron method invocations. + */ private final transient Counter cronMethodCounter; + /** + * Metric Counter for fixed rate method invocations. + */ private final transient Counter fixedRateMethodCounter; + /** + * Metric Counter for fixed rate initial delay method invocations. + */ private final transient Counter fixedRateInitialDelayMethodCounter; + /** + * Metric Counter for fixed delay method invocations. + */ private final transient Counter fixedDelayMethodCounter; + /** + * Metric Counter for fixed delay initial delay method invocations. + */ private final transient Counter fixedDelayInitialDelayMethodCounter; /** * The GreetingService business service. */ - private transient GreetingService greetingService; + private final transient GreetingService greetingService; /** * Construct a GreetingBatchBean with supplied dependencies. diff --git a/src/main/java/com/leanstacks/ws/model/Account.java b/src/main/java/com/leanstacks/ws/model/Account.java index f988612..7aeb2bd 100644 --- a/src/main/java/com/leanstacks/ws/model/Account.java +++ b/src/main/java/com/leanstacks/ws/model/Account.java @@ -21,24 +21,45 @@ public class Account extends TransactionalEntity { private static final long serialVersionUID = 1L; + /** + * Login username. + */ @NotNull private String username; + /** + * Login password. + */ @NotNull private String password; + /** + * Account enabled status indicator. + */ @NotNull private boolean enabled = true; + /** + * Credential status indicator. + */ @NotNull private boolean credentialsexpired; + /** + * Account expired status indicator. + */ @NotNull private boolean expired; + /** + * Account locked indicator. + */ @NotNull private boolean locked; + /** + * Authorization information. + */ @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) @JoinTable(name = "AccountRole", @@ -48,6 +69,9 @@ public class Account extends TransactionalEntity { referencedColumnName = "id")) private Set roles; + /** + * Create a new Account object. + */ public Account() { super(); } diff --git a/src/main/java/com/leanstacks/ws/model/Greeting.java b/src/main/java/com/leanstacks/ws/model/Greeting.java index 4670409..663c965 100644 --- a/src/main/java/com/leanstacks/ws/model/Greeting.java +++ b/src/main/java/com/leanstacks/ws/model/Greeting.java @@ -3,8 +3,6 @@ import javax.persistence.Entity; import javax.validation.constraints.NotNull; -import io.swagger.annotations.ApiModelProperty; - /** * The Greeting class is an entity model object. * @@ -15,17 +13,24 @@ public class Greeting extends TransactionalEntity { private static final long serialVersionUID = 1L; - @ApiModelProperty(value = "The actual text of the Greeting.", - required = true, - position = 100, - example = "Hello World!") + /** + * The text value. + */ @NotNull private String text; + /** + * Create a new Greeting object. + */ public Greeting() { super(); } + /** + * Create a new Greeting object with the supplied text value. + * + * @param text A String text value. + */ public Greeting(final String text) { super(); this.text = text; diff --git a/src/main/java/com/leanstacks/ws/model/TransactionalEntity.java b/src/main/java/com/leanstacks/ws/model/TransactionalEntity.java index cef678f..6230c78 100644 --- a/src/main/java/com/leanstacks/ws/model/TransactionalEntity.java +++ b/src/main/java/com/leanstacks/ws/model/TransactionalEntity.java @@ -15,8 +15,6 @@ import com.leanstacks.ws.util.RequestContext; -import io.swagger.annotations.ApiModelProperty; - /** * The parent class for all transactional persistent entities. * @@ -33,11 +31,6 @@ public class TransactionalEntity implements Serializable { /** * The primary key identifier. */ - @ApiModelProperty(value = "Primary key identifier", - required = false, - position = 1, - readOnly = true, - example = "1") @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -45,65 +38,35 @@ public class TransactionalEntity implements Serializable { /** * A secondary unique identifier which may be used as a reference to this entity by external systems. */ - @ApiModelProperty(value = "External reference identifier", - required = false, - position = 2, - readOnly = true, - example = "a1b2c3d4") @NotNull private String referenceId = UUID.randomUUID().toString(); /** * The entity instance version used for optimistic locking. */ - @ApiModelProperty(value = "Version of this model object since creation", - required = false, - position = Integer.MAX_VALUE - 500, - readOnly = true, - example = "1") @Version private Integer version; /** * A reference to the entity or process which created this entity instance. */ - @ApiModelProperty(value = "Identifies the object creator", - required = false, - position = Integer.MAX_VALUE - 400, - readOnly = true, - example = "user") @NotNull private String createdBy; /** * The timestamp when this entity instance was created. */ - @ApiModelProperty(value = "The object creation timestamp", - required = false, - position = Integer.MAX_VALUE - 300, - readOnly = true, - example = "1499418339522") @NotNull private Instant createdAt; /** * A reference to the entity or process which most recently updated this entity instance. */ - @ApiModelProperty(value = "Identifies the object updater", - required = false, - position = Integer.MAX_VALUE - 200, - readOnly = true, - example = "usertoo") private String updatedBy; /** * The timestamp when this entity instance was most recently updated. */ - @ApiModelProperty(value = "The object update timestamp", - required = false, - position = Integer.MAX_VALUE - 100, - readOnly = true, - example = "1499418343681") private Instant updatedAt; public Long getId() { diff --git a/src/main/java/com/leanstacks/ws/repository/AccountRepository.java b/src/main/java/com/leanstacks/ws/repository/AccountRepository.java index f0df7fd..6c29937 100644 --- a/src/main/java/com/leanstacks/ws/repository/AccountRepository.java +++ b/src/main/java/com/leanstacks/ws/repository/AccountRepository.java @@ -1,15 +1,16 @@ package com.leanstacks.ws.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import com.leanstacks.ws.model.Account; /** - * The AccountRepository interface is a Spring Data JPA data repository for - * Account entities. The AccountRepository provides all the data access - * behaviors exposed by JpaRepository and additional custom - * behaviors may be defined in this interface. + * The AccountRepository interface is a Spring Data JPA data repository for Account entities. The AccountRepository + * provides all the data access behaviors exposed by JpaRepository and additional custom behaviors may be + * defined in this interface. * * @author Matt Warman */ @@ -17,11 +18,11 @@ public interface AccountRepository extends JpaRepository { /** - * Query for a single Account entities by username. + * Query for a single Account entity by username. * * @param username The username value to query the repository. - * @return An Account or null if none found. + * @return An Optional Account. */ - Account findByUsername(String username); + Optional findByUsername(String username); } diff --git a/src/main/java/com/leanstacks/ws/security/AccountUserDetailsService.java b/src/main/java/com/leanstacks/ws/security/AccountUserDetailsService.java index 9e95090..c74fbc3 100644 --- a/src/main/java/com/leanstacks/ws/security/AccountUserDetailsService.java +++ b/src/main/java/com/leanstacks/ws/security/AccountUserDetailsService.java @@ -1,8 +1,8 @@ package com.leanstacks.ws.security; -import java.util.ArrayList; -import java.util.Collection; +import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,11 +43,9 @@ public class AccountUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException { logger.info("> loadUserByUsername {}", username); - final Account account = accountService.findByUsername(username); - if (account == null) { - // Not found... - throw new UsernameNotFoundException("Invalid credentials."); - } + final Optional accountOptional = accountService.findByUsername(username); + final Account account = accountOptional + .orElseThrow(() -> new UsernameNotFoundException("Invalid credentials.")); final Set roles = account.getRoles(); if (roles == null || roles.isEmpty()) { @@ -55,13 +53,11 @@ public UserDetails loadUserByUsername(final String username) throws UsernameNotF throw new UsernameNotFoundException("Invalid credentials."); } - final Collection grantedAuthorities = new ArrayList(); - for (final Role role : roles) { - grantedAuthorities.add(new SimpleGrantedAuthority(role.getCode())); - } + final Set authorities = roles.stream().map(role -> new SimpleGrantedAuthority(role.getCode())) + .collect(Collectors.toSet()); final User userDetails = new User(account.getUsername(), account.getPassword(), account.isEnabled(), - !account.isExpired(), !account.isCredentialsexpired(), !account.isLocked(), grantedAuthorities); + !account.isExpired(), !account.isCredentialsexpired(), !account.isLocked(), authorities); logger.info("< loadUserByUsername {}", username); return userDetails; diff --git a/src/main/java/com/leanstacks/ws/security/CorsProperties.java b/src/main/java/com/leanstacks/ws/security/CorsProperties.java new file mode 100644 index 0000000..9881951 --- /dev/null +++ b/src/main/java/com/leanstacks/ws/security/CorsProperties.java @@ -0,0 +1,178 @@ +package com.leanstacks.ws.security; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * A container for CORS configuration values. + * + * @author Matt Warman + * + */ +@ConfigurationProperties("leanstacks.cors") +public class CorsProperties { + + /** + * The path at which the CorsFilter is registered. The CorsFilter will handle all requests matching this path. + */ + private String filterRegistrationPath = "/**"; + + /** + * The value of the Access-Control-Allow-Credentials header. + */ + private Boolean allowCredentials = false; + + /** + * The value of the Access-Control-Allow-Headers header. + */ + private List allowedHeaders = Arrays.asList("accept", "content-type"); + + /** + * The value of the Access-Control-Allow-Methods header. + */ + private List allowedMethods = Arrays.asList("GET"); + + /** + * The value of the Access-Control-Allow-Origin header. + */ + private List allowedOrigins = Arrays.asList("*"); + + /** + * The value of the Access-Control-Expose-Headers header. + */ + private List exposedHeaders; + + /** + * The value of the Access-Control-Max-Age header. + */ + private Long maxAgeSeconds = 1800L; + + /** + * Returns the filter registration path. + * + * @return A String. + */ + public String getFilterRegistrationPath() { + return filterRegistrationPath; + } + + /** + * Sets the filter registration path. + * + * @param filterRegistrationPath A String. + */ + public void setFilterRegistrationPath(final String filterRegistrationPath) { + this.filterRegistrationPath = filterRegistrationPath; + } + + /** + * Returns the value of the Access-Control-Allow-Credentials header. + * + * @return A Boolean. + */ + public Boolean getAllowCredentials() { + return allowCredentials; + } + + /** + * Sets the value of the Access-Control-Allow-Credentials header. + * + * @param allowCredentials A Boolean. + */ + public void setAllowCredentials(final Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + + /** + * Returns the value of the Access-Control-Allow-Headers header. + * + * @return A List of Strings. + */ + public List getAllowedHeaders() { + return allowedHeaders; + } + + /** + * Sets the value of the Access-Control-Allow-Headers header. + * + * @param allowedHeaders A List of Strings. + */ + public void setAllowedHeaders(final List allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + /** + * Returns the value of the Access-Control-Allow-Methods header. + * + * @return A List of Strings. + */ + public List getAllowedMethods() { + return allowedMethods; + } + + /** + * Sets the value of the Access-Control-Allow-Methods header. + * + * @param allowedMethods A List of Strings. + */ + public void setAllowedMethods(final List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + /** + * Returns the value of the Access-Control-Allow-Origin header. + * + * @return A List of Strings. + */ + public List getAllowedOrigins() { + return allowedOrigins; + } + + /** + * Sets the value of the Access-Control-Allow-Origin header. + * + * @param allowedOrigins A List of Strings. + */ + public void setAllowedOrigins(final List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + /** + * Returns the value of the Access-Control-Expose-Headers header. + * + * @return A List of Strings. + */ + public List getExposedHeaders() { + return exposedHeaders; + } + + /** + * Sets the value of the Access-Control-Expose-Headers header. + * + * @param exposedHeaders A List of Strings. + */ + public void setExposedHeaders(final List exposedHeaders) { + this.exposedHeaders = exposedHeaders; + } + + /** + * Returns the value of the Access-Control-Max-Age header in seconds. + * + * @return A Long. + */ + public Long getMaxAgeSeconds() { + return maxAgeSeconds; + } + + /** + * Sets the value of the Access-Control-Max-Age header in seconds. + * + * @param maxAgeSeconds A Long. + */ + public void setMaxAgeSeconds(final Long maxAgeSeconds) { + this.maxAgeSeconds = maxAgeSeconds; + } + +} diff --git a/src/main/java/com/leanstacks/ws/SecurityConfiguration.java b/src/main/java/com/leanstacks/ws/security/SecurityConfiguration.java similarity index 62% rename from src/main/java/com/leanstacks/ws/SecurityConfiguration.java rename to src/main/java/com/leanstacks/ws/security/SecurityConfiguration.java index c336328..b45fe69 100644 --- a/src/main/java/com/leanstacks/ws/SecurityConfiguration.java +++ b/src/main/java/com/leanstacks/ws/security/SecurityConfiguration.java @@ -1,11 +1,12 @@ -package com.leanstacks.ws; +package com.leanstacks.ws.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.http.HttpMethod; 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; @@ -14,9 +15,9 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; - -import com.leanstacks.ws.security.AccountAuthenticationProvider; -import com.leanstacks.ws.security.RestBasicAuthenticationEntryPoint; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; /** * The SecurityConfiguration class provides a centralized location for application security configuration. This class @@ -26,6 +27,7 @@ */ @Configuration @EnableWebSecurity +@EnableConfigurationProperties(CorsProperties.class) public class SecurityConfiguration { /** @@ -67,21 +69,51 @@ public void configureGlobal(final AuthenticationManagerBuilder auth) throws Exce @Order(1) public static class ApiWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { + /** + * The CORS configuration. + */ + @Autowired + private transient CorsProperties corsProperties; + + /** + * Defines a ConfigurationSource for CORS attributes. + * + * @return A CorsConfigurationSource. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + final CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(corsProperties.getAllowedOrigins()); + configuration.setAllowedMethods(corsProperties.getAllowedMethods()); + configuration.setAllowedHeaders(corsProperties.getAllowedHeaders()); + configuration.setAllowCredentials(corsProperties.getAllowCredentials()); + configuration.setExposedHeaders(corsProperties.getExposedHeaders()); + configuration.setMaxAge(corsProperties.getMaxAgeSeconds()); + + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration(corsProperties.getFilterRegistrationPath(), configuration); + return source; + } + @Override protected void configure(final HttpSecurity http) throws Exception { // @formatter:off http - .csrf().disable() - .antMatcher("/api/**") - .authorizeRequests() - .anyRequest().hasRole("USER") - .and() - .httpBasic().authenticationEntryPoint(apiAuthenticationEntryPoint()) - .and() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + .cors() + .and() + .csrf().disable() + .requestMatchers().antMatchers("/api/**") + .and() + .authorizeRequests() + .antMatchers(HttpMethod.OPTIONS).permitAll() + .anyRequest().hasRole("USER") + .and() + .httpBasic().authenticationEntryPoint(apiAuthenticationEntryPoint()) + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // @formatter:on @@ -118,18 +150,18 @@ protected void configure(final HttpSecurity http) throws Exception { // @formatter:off http - .csrf().disable() - .requestMatcher(EndpointRequest.toAnyEndpoint()) - .authorizeRequests() - // Permit access to health check - .requestMatchers(EndpointRequest.to("health")).permitAll() - // Require authorization for everthing else - .anyRequest().hasRole("SYSADMIN") - .and() - .httpBasic().authenticationEntryPoint(actuatorAuthenticationEntryPoint()) - .and() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + .csrf().disable() + .requestMatcher(EndpointRequest.toAnyEndpoint()) + .authorizeRequests() + // Permit access to health check + .requestMatchers(EndpointRequest.to("health")).permitAll() + // Require authorization for everthing else + .anyRequest().hasRole("SYSADMIN") + .and() + .httpBasic().authenticationEntryPoint(actuatorAuthenticationEntryPoint()) + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // @formatter:on @@ -150,33 +182,4 @@ public AuthenticationEntryPoint actuatorAuthenticationEntryPoint() { } - /** - * This inner class configures the WebSecurityConfigurerAdapter instance for any remaining context paths not handled - * by other adapters. - * - * @author Matt Warman - */ - @Profile("docs") - @Configuration - @Order(3) - public static class FormLoginWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter { - - @Override - protected void configure(final HttpSecurity http) throws Exception { - - // @formatter:off - - http - .csrf().disable() - .authorizeRequests() - .anyRequest().authenticated() - .and() - .formLogin(); - - // @formatter:on - - } - - } - } diff --git a/src/main/java/com/leanstacks/ws/service/AccountService.java b/src/main/java/com/leanstacks/ws/service/AccountService.java index b325afb..2569edb 100644 --- a/src/main/java/com/leanstacks/ws/service/AccountService.java +++ b/src/main/java/com/leanstacks/ws/service/AccountService.java @@ -1,5 +1,7 @@ package com.leanstacks.ws.service; +import java.util.Optional; + import com.leanstacks.ws.model.Account; /** @@ -19,8 +21,8 @@ public interface AccountService { * Find an Account by the username attribute value. * * @param username A String username to query the repository. - * @return An Account instance or null if none found. + * @return An Optional wrapped Account. */ - Account findByUsername(String username); + Optional findByUsername(String username); } diff --git a/src/main/java/com/leanstacks/ws/service/AccountServiceBean.java b/src/main/java/com/leanstacks/ws/service/AccountServiceBean.java index 4367b5b..62ce6de 100644 --- a/src/main/java/com/leanstacks/ws/service/AccountServiceBean.java +++ b/src/main/java/com/leanstacks/ws/service/AccountServiceBean.java @@ -1,5 +1,7 @@ package com.leanstacks.ws.service; +import java.util.Optional; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -29,13 +31,13 @@ public class AccountServiceBean implements AccountService { private transient AccountRepository accountRepository; @Override - public Account findByUsername(final String username) { + public Optional findByUsername(final String username) { logger.info("> findByUsername"); - final Account account = accountRepository.findByUsername(username); + final Optional accountOptional = accountRepository.findByUsername(username); logger.info("< findByUsername"); - return account; + return accountOptional; } } diff --git a/src/main/java/com/leanstacks/ws/service/GreetingService.java b/src/main/java/com/leanstacks/ws/service/GreetingService.java index 21c337c..a0c3f16 100644 --- a/src/main/java/com/leanstacks/ws/service/GreetingService.java +++ b/src/main/java/com/leanstacks/ws/service/GreetingService.java @@ -1,6 +1,7 @@ package com.leanstacks.ws.service; -import java.util.Collection; +import java.util.List; +import java.util.Optional; import com.leanstacks.ws.model.Greeting; @@ -19,17 +20,17 @@ public interface GreetingService { /** * Find all Greeting entities. * - * @return A Collection of Greeting objects. + * @return A List of Greeting objects. */ - Collection findAll(); + List findAll(); /** - * Find a single Greeting entity by primary key identifier. + * Find a single Greeting entity by primary key identifier. Returns an Optional wrapped Greeting. * - * @param id A BigInteger primary key identifier. - * @return A Greeting or null if none found. + * @param id A Long primary key identifier. + * @return A Optional Greeting */ - Greeting findOne(Long id); + Optional findOne(Long id); /** * Persists a Greeting entity in the data store. @@ -50,7 +51,7 @@ public interface GreetingService { /** * Removes a previously persisted Greeting entity from the data store. * - * @param id A BigInteger primary key identifier. + * @param id A Long primary key identifier. */ void delete(Long id); diff --git a/src/main/java/com/leanstacks/ws/service/GreetingServiceBean.java b/src/main/java/com/leanstacks/ws/service/GreetingServiceBean.java index f240ea6..64032e7 100644 --- a/src/main/java/com/leanstacks/ws/service/GreetingServiceBean.java +++ b/src/main/java/com/leanstacks/ws/service/GreetingServiceBean.java @@ -1,11 +1,8 @@ package com.leanstacks.ws.service; -import java.util.Collection; +import java.util.List; import java.util.Optional; -import javax.persistence.EntityExistsException; -import javax.persistence.NoResultException; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -35,12 +32,29 @@ public class GreetingServiceBean implements GreetingService { */ private static final Logger logger = LoggerFactory.getLogger(GreetingServiceBean.class); - // Metric Counters + /** + * Metric Counter for findAll method invocations. + */ private final transient Counter findAllMethodInvocationCounter; + /** + * Metric Counter for findOne method invocations. + */ private final transient Counter findOneMethodInvocationCounter; + /** + * Metric Counter for create method invocations. + */ private final transient Counter createMethodInvocationCounter; + /** + * Metric Counter for update method invocations. + */ private final transient Counter updateMethodInvocationCounter; + /** + * Metric Counter for delete method invocations. + */ private final transient Counter deleteMethodInvocationCounter; + /** + * Metric Counter for evictCache method invocations. + */ private final transient Counter evictCacheMethodInvocationCounter; /** @@ -66,12 +80,12 @@ public GreetingServiceBean(final GreetingRepository greetingRepository, final Me } @Override - public Collection findAll() { + public List findAll() { logger.info("> findAll"); findAllMethodInvocationCounter.increment(); - final Collection greetings = greetingRepository.findAll(); + final List greetings = greetingRepository.findAll(); logger.info("< findAll"); return greetings; @@ -80,19 +94,19 @@ public Collection findAll() { @Cacheable(value = Application.CACHE_GREETINGS, key = "#id") @Override - public Greeting findOne(final Long id) { + public Optional findOne(final Long id) { logger.info("> findOne {}", id); findOneMethodInvocationCounter.increment(); - final Optional result = greetingRepository.findById(id); + final Optional greetingOptional = greetingRepository.findById(id); logger.info("< findOne {}", id); - return result.isPresent() ? result.get() : null; + return greetingOptional; } @CachePut(value = Application.CACHE_GREETINGS, - key = "#result.id") + key = "#result?.id") @Transactional @Override public Greeting create(final Greeting greeting) { @@ -106,7 +120,7 @@ public Greeting create(final Greeting greeting) { if (greeting.getId() != null) { logger.error("Attempted to create a Greeting, but id attribute was not null."); logger.info("< create"); - throw new EntityExistsException( + throw new IllegalArgumentException( "Cannot create new Greeting with supplied id. The id attribute must be null to create an entity."); } @@ -125,15 +139,10 @@ public Greeting update(final Greeting greeting) { updateMethodInvocationCounter.increment(); - // Ensure the entity object to be updated exists in the repository to - // prevent the default behavior of save() which will persist a new + // findOne returns an Optional which will throw NoSuchElementException when null. + // This will prevent the default behavior of save() which will persist a new // entity if the entity matching the id does not exist - final Greeting greetingToUpdate = findOne(greeting.getId()); - if (greetingToUpdate == null) { - logger.error("Attempted to update a Greeting, but the entity does not exist."); - logger.info("< update {}", greeting.getId()); - throw new NoResultException("Requested Greeting not found."); - } + final Greeting greetingToUpdate = findOne(greeting.getId()).get(); greetingToUpdate.setText(greeting.getText()); final Greeting updatedGreeting = greetingRepository.save(greetingToUpdate); diff --git a/src/main/java/com/leanstacks/ws/web/api/ExceptionDetail.java b/src/main/java/com/leanstacks/ws/web/api/ExceptionDetail.java index 0d9c577..de636ca 100644 --- a/src/main/java/com/leanstacks/ws/web/api/ExceptionDetail.java +++ b/src/main/java/com/leanstacks/ws/web/api/ExceptionDetail.java @@ -1,5 +1,7 @@ package com.leanstacks.ws.web.api; +import java.time.Instant; + /** * The ExceptionDetail class models information about a web service request which results in an Exception. This * information may be returned to the client. @@ -10,15 +12,15 @@ public class ExceptionDetail { /** - * A timestamp expressed in milliseconds. + * The time the exception occurred. */ - private long timestamp; + private Instant timestamp; /** * The HTTP method (e.g. GET, POST, etc.) */ private String method = ""; /** - * The web service servlet path. + * The web service context path. */ private String path = ""; /** @@ -42,24 +44,24 @@ public class ExceptionDetail { * Construct an ExceptionDetail. */ public ExceptionDetail() { - this.timestamp = System.currentTimeMillis(); + this.timestamp = Instant.now(); } /** * Returns the timestamp attribute value. * - * @return A long. + * @return An Instant. */ - public long getTimestamp() { + public Instant getTimestamp() { return timestamp; } /** * Sets the timestamp attribute value. * - * @param timestamp A long. + * @param timestamp An Instant. */ - public void setTimestamp(final long timestamp) { + public void setTimestamp(final Instant timestamp) { this.timestamp = timestamp; } 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 fdd5ada..b54d2a5 100644 --- a/src/main/java/com/leanstacks/ws/web/api/GreetingController.java +++ b/src/main/java/com/leanstacks/ws/web/api/GreetingController.java @@ -1,6 +1,7 @@ package com.leanstacks.ws.web.api; -import java.util.Collection; +import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -8,24 +9,21 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.leanstacks.ws.model.Greeting; import com.leanstacks.ws.service.EmailService; import com.leanstacks.ws.service.GreetingService; -import io.swagger.annotations.ApiImplicitParam; -import io.swagger.annotations.ApiImplicitParams; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiParam; - /** * The GreetingController class is a RESTful web service controller. The @RestController annotation informs * Spring that each @RequestMapping method returns a @ResponseBody. @@ -33,6 +31,7 @@ * @author Matt Warman */ @RestController +@RequestMapping("/api/greetings") public class GreetingController { /** @@ -56,27 +55,16 @@ public class GreetingController { * Web service endpoint to fetch all Greeting entities. The service returns the collection of Greeting entities as * JSON. * - * @return A ResponseEntity containing a Collection of Greeting objects. + * @return A List of Greeting objects. */ - @ApiOperation(value = "${GreetingController.getGreetings.title}", - notes = "${GreetingController.getGreetings.notes}", - response = Greeting.class, - responseContainer = "List") - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings", - method = RequestMethod.GET, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> getGreetings() { + @GetMapping + public List getGreetings() { logger.info("> getGreetings"); - final Collection greetings = greetingService.findAll(); + final List greetings = greetingService.findAll(); logger.info("< getGreetings"); - return new ResponseEntity>(greetings, HttpStatus.OK); + return greetings; } /** @@ -89,31 +77,16 @@ public ResponseEntity> getGreetings() { *

* * @param id A Long URL path variable containing the Greeting primary key identifier. - * @return A ResponseEntity containing a single Greeting object, if found, and a HTTP status code as described in - * the method comment. + * @return A Greeting object, if found, and a HTTP status code as described in the method comment. */ - @ApiOperation(value = "${GreetingController.getGreeting.title}", - notes = "${GreetingController.getGreeting.notes}", - response = Greeting.class) - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings/{id}", - method = RequestMethod.GET, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity getGreeting(@ApiParam("Greeting ID") @PathVariable final Long id) { + @GetMapping("/{id}") + public Greeting getGreeting(@PathVariable final Long id) { logger.info("> getGreeting"); - final Greeting greeting = greetingService.findOne(id); - if (greeting == null) { - logger.info("< getGreeting"); - return new ResponseEntity(HttpStatus.NOT_FOUND); - } + final Optional greetingOptional = greetingService.findOne(id); logger.info("< getGreeting"); - return new ResponseEntity(greeting, HttpStatus.OK); + return greetingOptional.get(); } /** @@ -123,33 +96,21 @@ public ResponseEntity getGreeting(@ApiParam("Greeting ID") @PathVariab *

*

* If created successfully, the persisted Greeting is returned as JSON with HTTP status 201. If not created - * successfully, the service returns an empty response body with HTTP status 500. + * successfully, the service returns an ExceptionDetail response body with HTTP status 400 or 500. *

* * @param greeting The Greeting object to be created. - * @return A ResponseEntity containing a single Greeting object, if created successfully, and a HTTP status code as - * described in the method comment. + * @return A Greeting object, if created successfully, and a HTTP status code as described in the method comment. */ - @ApiOperation(value = "${GreetingController.createGreeting.title}", - notes = "${GreetingController.createGreeting.notes}", - response = Greeting.class, - code = 201) - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings", - method = RequestMethod.POST, - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity createGreeting(@RequestBody final Greeting greeting) { + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Greeting createGreeting(@RequestBody final Greeting greeting) { logger.info("> createGreeting"); final Greeting savedGreeting = greetingService.create(greeting); logger.info("< createGreeting"); - return new ResponseEntity(savedGreeting, HttpStatus.CREATED); + return savedGreeting; } /** @@ -159,28 +120,15 @@ public ResponseEntity createGreeting(@RequestBody final Greeting greet *

*

* If updated successfully, the persisted Greeting is returned as JSON with HTTP status 200. If not found, the - * service returns an empty response body and HTTP status 404. If not updated successfully, the service returns an - * empty response body with HTTP status 500. + * service returns an ExceptionDetail response body and HTTP status 404. If not updated successfully, the service + * returns an empty response body with HTTP status 400 or 500. *

* * @param greeting The Greeting object to be updated. - * @return A ResponseEntity containing a single Greeting object, if updated successfully, and a HTTP status code as - * described in the method comment. + * @return A Greeting object, if updated successfully, and a HTTP status code as described in the method comment. */ - @ApiOperation(value = "${GreetingController.updateGreeting.title}", - notes = "${GreetingController.updateGreeting.notes}", - response = Greeting.class) - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings/{id}", - method = RequestMethod.PUT, - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity updateGreeting(@ApiParam("Greeting ID") @PathVariable("id") final Long id, - @RequestBody final Greeting greeting) { + @PutMapping("/{id}") + public Greeting updateGreeting(@PathVariable("id") final Long id, @RequestBody final Greeting greeting) { logger.info("> updateGreeting"); greeting.setId(id); @@ -188,7 +136,7 @@ public ResponseEntity updateGreeting(@ApiParam("Greeting ID") @PathVar final Greeting updatedGreeting = greetingService.update(greeting); logger.info("< updateGreeting"); - return new ResponseEntity(updatedGreeting, HttpStatus.OK); + return updatedGreeting; } /** @@ -198,29 +146,19 @@ public ResponseEntity updateGreeting(@ApiParam("Greeting ID") @PathVar *

*

* If deleted successfully, the service returns an empty response body with HTTP status 204. If not deleted - * successfully, the service returns an empty response body with HTTP status 500. + * successfully, the service returns an ExceptionDetail response body with HTTP status 500. *

* * @param id A Long URL path variable containing the Greeting primary key identifier. - * @return A ResponseEntity with an empty response body and a HTTP status code as described in the method comment. */ - @ApiOperation(value = "${GreetingController.deleteGreeting.title}", - notes = "${GreetingController.deleteGreeting.notes}", - code = 204) - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings/{id}", - method = RequestMethod.DELETE) - public ResponseEntity deleteGreeting(@ApiParam("Greeting ID") @PathVariable("id") final Long id) { + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteGreeting(@PathVariable("id") final Long id) { logger.info("> deleteGreeting"); greetingService.delete(id); logger.info("< deleteGreeting"); - return new ResponseEntity(HttpStatus.NO_CONTENT); } /** @@ -229,40 +167,24 @@ public ResponseEntity deleteGreeting(@ApiParam("Greeting ID") @PathVariabl *

*

* If found, the Greeting is returned as JSON with HTTP status 200 and sent via Email. If not found, the service - * returns an empty response body with HTTP status 404. + * returns an Exception response body with HTTP status 404. *

* * @param id A Long URL path variable containing the Greeting primary key identifier. * @param waitForAsyncResult A boolean indicating if the web service should wait for the asynchronous email * transmission. - * @return A ResponseEntity containing a single Greeting object, if found, and a HTTP status code as described in - * the method comment. + * @return A Greeting object, if found, and a HTTP status code as described in the method comment. */ - @ApiOperation(value = "${GreetingController.sendGreeting.title}", - notes = "${GreetingController.sendGreeting.notes}", - response = Greeting.class) - @ApiImplicitParams(@ApiImplicitParam(name = "Authorization", - value = "Basic auth_creds", - required = true, - dataType = "string", - paramType = "header")) - @RequestMapping(value = "/api/greetings/{id}/send", - method = RequestMethod.POST, - produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity sendGreeting(@ApiParam("Greeting ID") @PathVariable("id") final Long id, - @ApiParam("Wait for Response") @RequestParam(value = "wait", - defaultValue = "false") final boolean waitForAsyncResult) { + @PostMapping("/{id}/send") + public Greeting sendGreeting(@PathVariable("id") final Long id, @RequestParam(value = "wait", + defaultValue = "false") final boolean waitForAsyncResult) { logger.info("> sendGreeting"); Greeting greeting; try { - greeting = greetingService.findOne(id); - if (greeting == null) { - logger.info("< sendGreeting"); - return new ResponseEntity(HttpStatus.NOT_FOUND); - } + greeting = greetingService.findOne(id).get(); if (waitForAsyncResult) { final Future asyncResponse = emailService.sendAsyncWithResult(greeting); @@ -273,11 +195,11 @@ public ResponseEntity sendGreeting(@ApiParam("Greeting ID") @PathVaria } } catch (ExecutionException | InterruptedException ex) { logger.error("A problem occurred sending the Greeting.", ex); - return new ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR); + throw new IllegalStateException(ex); } logger.info("< sendGreeting"); - return new ResponseEntity(greeting, HttpStatus.OK); + return greeting; } diff --git a/src/main/java/com/leanstacks/ws/web/api/RestResponseEntityExceptionHandler.java b/src/main/java/com/leanstacks/ws/web/api/RestResponseEntityExceptionHandler.java index 600ddc4..f39a691 100644 --- a/src/main/java/com/leanstacks/ws/web/api/RestResponseEntityExceptionHandler.java +++ b/src/main/java/com/leanstacks/ws/web/api/RestResponseEntityExceptionHandler.java @@ -23,14 +23,17 @@ @ControllerAdvice public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { + /** + * The Logger for this Class. + */ private static final Logger logger = LoggerFactory.getLogger(RestResponseEntityExceptionHandler.class); /** - * Handles JPA NoResultExceptions thrown from web service controller methods. Creates a response with an empty body - * and HTTP status code 404, not found. + * Handles JPA NoResultExceptions thrown from web service controller methods. Creates a response with an + * ExceptionDetail body and HTTP status code 404, not found. * * @param ex A NoResultException instance. - * @return A ResponseEntity with an empty response body and HTTP status code 404. + * @return A ResponseEntity with an ExceptionDetail response body and HTTP status code 404. */ @ExceptionHandler(NoResultException.class) public ResponseEntity handleNoResultException(final NoResultException ex, final WebRequest request) { @@ -43,11 +46,11 @@ public ResponseEntity handleNoResultException(final NoResultException ex } /** - * Handles JPA NoSuchElementException thrown when an empty Optional is accessed. Creates a response with an empty - * body and HTTP status code 404, not found. + * Handles JPA NoSuchElementException thrown when an empty Optional is accessed. Creates a response with an + * ExceptionDetail body and HTTP status code 404, not found. * * @param ex A NoSuchElementException instance. - * @return A ResponseEntity with an empty response body and HTTP status code 404. + * @return A ResponseEntity with an ExceptionDetail response body and HTTP status code 404. */ @ExceptionHandler(NoSuchElementException.class) public ResponseEntity handleNoSuchElementException(final NoSuchElementException ex, @@ -62,28 +65,46 @@ public ResponseEntity handleNoSuchElementException(final NoSuchElementEx /** * Handles EmptyResultDataAccessException thrown from web service controller methods. Creates a response with an - * empty body and HTTP status code 404, not found. + * ExceptionDetail body and HTTP status code 404, not found. * * @param ex An EmptyResultDataAccessException instance. - * @return A ResponseEntity with an empty response body and HTTP status code 404. + * @return A ResponseEntity with an ExceptionDetail response body and HTTP status code 404. */ @ExceptionHandler(EmptyResultDataAccessException.class) public ResponseEntity handleEmptyResultDataAccessException(final EmptyResultDataAccessException ex, final WebRequest request) { logger.info("> handleEmptyResultDataAccessException"); - logger.warn("- EmptyResultDataAccessException: ", ex); + logger.info("- EmptyResultDataAccessException: ", ex); final ExceptionDetail detail = new ExceptionDetailBuilder().exception(ex).httpStatus(HttpStatus.NOT_FOUND) .webRequest(request).build(); logger.info("< handleEmptyResultDataAccessException"); return handleExceptionInternal(ex, detail, new HttpHeaders(), HttpStatus.NOT_FOUND, request); } + /** + * Handles IllegalArgumentException thrown from web service controller methods. Creates a response with an + * ExceptionDetail body and HTTP status code 400, not found. + * + * @param ex An IllegalArgumentException instance. + * @return A ResponseEntity with an ExceptionDetail response body and HTTP status code 400. + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(final IllegalArgumentException ex, + final WebRequest request) { + logger.info("> handleIllegalArgumentException"); + logger.warn("- IllegalArgumentException: ", ex); + final ExceptionDetail detail = new ExceptionDetailBuilder().exception(ex).httpStatus(HttpStatus.BAD_REQUEST) + .webRequest(request).build(); + logger.info("< handleIllegalArgumentException"); + return handleExceptionInternal(ex, detail, new HttpHeaders(), HttpStatus.BAD_REQUEST, request); + } + /** * Handles all Exceptions not addressed by more specific @ExceptionHandler methods. Creates a response - * with the Exception detail in the response body as JSON and a HTTP status code of 500, internal server error. + * with the ExceptionDetail in the response body as JSON and a HTTP status code of 500, internal server error. * * @param ex An Exception instance. - * @return A ResponseEntity containing a the Exception attributes in the response body and a HTTP status code 500. + * @return A ResponseEntity containing a the ExceptionDetail in the response body and a HTTP status code 500. */ @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(final Exception ex, final WebRequest request) { diff --git a/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java b/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java deleted file mode 100644 index 21594d1..0000000 --- a/src/main/java/com/leanstacks/ws/web/docs/ApiDocsController.java +++ /dev/null @@ -1,28 +0,0 @@ -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 "redirect:swagger-ui.html"; - } - -} diff --git a/src/main/java/com/leanstacks/ws/web/filter/SimpleCorsFilter.java b/src/main/java/com/leanstacks/ws/web/filter/SimpleCorsFilter.java deleted file mode 100644 index 60de027..0000000 --- a/src/main/java/com/leanstacks/ws/web/filter/SimpleCorsFilter.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.leanstacks.ws.web.filter; - -import java.io.IOException; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServletResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.GenericFilterBean; - -/** - * The SimpleCORSFilter class is a standard web Filter which intercepts all inbound HTTP requests. The filter sets - * several Headers on the HTTP response which inform a browser that the web services handle Cross-Origin requests. - * - * @author Matt Warman - */ -@Component -public class SimpleCorsFilter extends GenericFilterBean { - - /** - * The Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(SimpleCorsFilter.class); - - @Override - public void doFilter(final ServletRequest req, final ServletResponse resp, final FilterChain chain) - throws IOException, ServletException { - logger.info("> doFilter"); - - final HttpServletResponse response = (HttpServletResponse) resp; - response.setHeader("Access-Control-Allow-Origin", "*"); - response.setHeader("Access-Control-Allow-Methods", "DELETE, GET, OPTIONS, PATCH, POST, PUT"); - response.setHeader("Access-Control-Max-Age", "3600"); - response.setHeader("Access-Control-Allow-Headers", "x-requested-with, content-type"); - - chain.doFilter(req, resp); - logger.info("< doFilter"); - } - -} diff --git a/src/main/resources/config/application-docs.yaml b/src/main/resources/config/application-docs.yaml deleted file mode 100644 index 0666f6e..0000000 --- a/src/main/resources/config/application-docs.yaml +++ /dev/null @@ -1,19 +0,0 @@ -GreetingController: - getGreetings: - title: 'Find all Greetings' - notes: 'Retrieves a Collection of Greeting objects from the data store and returns them as JSON.' - getGreeting: - title: 'Find Greeting by ID' - notes: 'Retrieves a Greeting object from the data store by its Identifier and returns it as JSON.' - createGreeting: - title: 'Create a Greeting' - notes: 'Creates a Greeting object in the data store and returns it as JSON.' - updateGreeting: - title: 'Update a Greeting' - notes: 'Updates a Greeting object in the data store and returns it as JSON.' - deleteGreeting: - title: 'Delete a Greeting' - notes: 'Deletes a Greeting object from the data store.' - sendGreeting: - title: 'Send a Greeting' - notes: 'Send a Greeting object via Email and returns it as JSON.' diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index 9dfa0bb..e5281f9 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -4,9 +4,9 @@ ## # Profile Configuration -# profiles: hsqldb, mysql, batch, docs +# profiles: hsqldb, mysql, batch ## -spring.profiles.active=hsqldb,batch,docs +spring.profiles.active=hsqldb,batch ## # Web Server Configuration @@ -59,6 +59,18 @@ logging.level.com.leanstacks.ws=DEBUG logging.level.org.springboot=INFO logging.level.org.springframework=INFO logging.level.org.springframework.security=INFO +logging.level.org.springframework.restdocs=DEBUG # Uncomment the 2 hibernate appenders below to show SQL and params in logs logging.level.org.hibernate.SQL=DEBUG #logging.level.org.hibernate.type.descriptor.sql=TRACE + +## +# CORS Configuration +## +leanstacks.cors.filter-registration-path=/** +leanstacks.cors.allow-credentials=false +leanstacks.cors.allowed-headers=accept,authorization,content-type +leanstacks.cors.allowed-methods=GET,OPTIONS,POST,PUT,PATCH,DELETE +leanstacks.cors.allowed-origins=* +leanstacks.cors.exposed-headers= +leanstacks.cors.max-age-seconds=3600 diff --git a/src/test/java/com/leanstacks/ws/AbstractDocTest.java b/src/test/java/com/leanstacks/ws/AbstractDocTest.java new file mode 100644 index 0000000..e452d40 --- /dev/null +++ b/src/test/java/com/leanstacks/ws/AbstractDocTest.java @@ -0,0 +1,77 @@ +package com.leanstacks.ws; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.restdocs.JUnitRestDocumentation; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import com.leanstacks.ws.util.RequestContext; + +/** + * The AbstractDocTest class is the parent of all JUnit test classes which create Spring REST Docs. This class + * configures the test ApplicationContext and test runner environment to facilitate the creation of API documentation + * via Spring REST Docs. + * + * @author Matt Warman + */ +public abstract class AbstractDocTest { + + /** + * A MockMvc instance configured with Spring REST Docs configuration. + */ + protected transient MockMvc mockMvc; + + /** + * A WebApplicationContext instance. + */ + @Autowired + private transient WebApplicationContext context; + + /** + * A JUnit 4.x Rule for Spring REST Documentation generation. Note that the snippet output directory is only + * provided because this project contains both 'build.gradle' and 'pom.xml' files. Spring REST Docs uses those files + * to auto-detect the build system and automatically sets certain configuration values which cannot be overridden. + */ + @Rule + public transient JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation("build/generated-snippets"); + + /** + * Perform set up activities before each unit test. Invoked by the JUnit framework. + */ + @Before + public void before() { + RequestContext.setUsername(AbstractTest.USERNAME); + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(this.restDocumentation).uris().withScheme("https") + .withHost("api.leanstacks.net").withPort(443).and().operationPreprocessors() + .withRequestDefaults(prettyPrint()).withResponseDefaults(prettyPrint())) + .build(); + doBeforeEachTest(); + } + + /** + * Perform initialization tasks before the execution of each test method. + */ + public abstract void doBeforeEachTest(); + + /** + * Perform clean up activities after each unit test. Invoked by the JUnit framework. + */ + @After + public void after() { + doAfterEachTest(); + } + + /** + * Perform clean up tasks after the execution of each test method. + */ + public abstract void doAfterEachTest(); + +} diff --git a/src/test/java/com/leanstacks/ws/AbstractTest.java b/src/test/java/com/leanstacks/ws/AbstractTest.java index 77c0fa2..edcb804 100644 --- a/src/test/java/com/leanstacks/ws/AbstractTest.java +++ b/src/test/java/com/leanstacks/ws/AbstractTest.java @@ -18,6 +18,9 @@ public abstract class AbstractTest { */ public static final String USERNAME = "unittest"; + /** + * Tasks performed before each test method. + */ @Before public void before() { RequestContext.setUsername(AbstractTest.USERNAME); @@ -25,17 +28,22 @@ public void before() { } /** - * Perform initialization tasks before the execution of each test method. + * Perform initialization tasks before the execution of each test method. Concrete test classes may override this + * method to implement class-specific tasks. */ public abstract void doBeforeEachTest(); + /** + * Tasks performed after each test method. + */ @After public void after() { doAfterEachTest(); } /** - * Perform clean up tasks after the execution of each test method. + * Perform clean up tasks after the execution of each test method. Concrete test classes may override this method to + * implement class-specific tasks. */ public abstract void doAfterEachTest(); diff --git a/src/test/java/com/leanstacks/ws/service/GreetingServiceTest.java b/src/test/java/com/leanstacks/ws/service/GreetingServiceTest.java index 793e506..59f3767 100644 --- a/src/test/java/com/leanstacks/ws/service/GreetingServiceTest.java +++ b/src/test/java/com/leanstacks/ws/service/GreetingServiceTest.java @@ -1,9 +1,9 @@ package com.leanstacks.ws.service; import java.util.Collection; - -import javax.persistence.EntityExistsException; -import javax.persistence.NoResultException; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; import org.junit.Assert; import org.junit.Test; @@ -24,8 +24,14 @@ @BasicTransactionalTest public class GreetingServiceTest extends AbstractTest { + /** + * Constant 'test'. + */ private static final String VALUE_TEXT = "test"; + /** + * The GreetingService business service. + */ @Autowired private transient GreetingService greetingService; @@ -39,6 +45,9 @@ public void doAfterEachTest() { // perform test clean up } + /** + * Test fetch a collection of Greetings. + */ @Test public void testGetGreetings() { @@ -49,29 +58,38 @@ public void testGetGreetings() { } + /** + * Test fetch a single Greeting. + */ @Test public void testGetGreeting() { final Long id = Long.valueOf(1); - final Greeting greeting = greetingService.findOne(id); + final Greeting greeting = greetingService.findOne(id).get(); Assert.assertNotNull("failure - expected not null", greeting); Assert.assertEquals("failure - expected greeting.id match", id, greeting.getId()); } + /** + * Test fetch a single greeting with invalid identifier. + */ @Test public void testGetGreetingNotFound() { final Long id = Long.MAX_VALUE; - final Greeting greeting = greetingService.findOne(id); + final Optional greetingOptional = greetingService.findOne(id); - Assert.assertNull("failure - expected null", greeting); + Assert.assertTrue("failure - expected null", greetingOptional.isEmpty()); } + /** + * Test create a Greeting. + */ @Test public void testCreateGreeting() { @@ -84,38 +102,40 @@ public void testCreateGreeting() { Assert.assertNotNull("failure - expected greeting.id not null", createdGreeting.getId()); Assert.assertEquals("failure - expected greeting.text match", VALUE_TEXT, createdGreeting.getText()); - final Collection greetings = greetingService.findAll(); + final List greetings = greetingService.findAll(); Assert.assertEquals("failure - expected 3 greetings", 3, greetings.size()); } + /** + * Test create a Greeting with invalid data. + */ @Test public void testCreateGreetingWithId() { - Exception exception = null; - final Greeting greeting = new Greeting(); greeting.setId(Long.MAX_VALUE); greeting.setText(VALUE_TEXT); try { greetingService.create(greeting); - } catch (EntityExistsException eee) { - exception = eee; + Assert.fail("failure - expected exception"); + } catch (IllegalArgumentException ex) { + Assert.assertNotNull("failure - expected exception not null", ex); } - Assert.assertNotNull("failure - expected exception", exception); - Assert.assertTrue("failure - expected EntityExistsException", exception instanceof EntityExistsException); - } + /** + * Test update a Greeting. + */ @Test public void testUpdateGreeting() { final Long id = Long.valueOf(1); - final Greeting greeting = greetingService.findOne(id); + final Greeting greeting = greetingService.findOne(id).get(); Assert.assertNotNull("failure - expected greeting not null", greeting); @@ -129,44 +149,46 @@ public void testUpdateGreeting() { } + /** + * Test update a Greeting which does not exist. + */ @Test public void testUpdateGreetingNotFound() { - Exception exception = null; - final Greeting greeting = new Greeting(); greeting.setId(Long.MAX_VALUE); greeting.setText("test"); try { greetingService.update(greeting); - } catch (NoResultException nre) { - exception = nre; + Assert.fail("failure - expected exception"); + } catch (NoSuchElementException ex) { + Assert.assertNotNull("failure - expected exception not null", ex); } - Assert.assertNotNull("failure - expected exception", exception); - Assert.assertTrue("failure - expected NoResultException", exception instanceof NoResultException); - } + /** + * Test delete a Greeting. + */ @Test public void testDeleteGreeting() { final Long id = Long.valueOf(1); - final Greeting greeting = greetingService.findOne(id); + final Greeting greeting = greetingService.findOne(id).get(); Assert.assertNotNull("failure - expected greeting not null", greeting); greetingService.delete(id); - final Collection greetings = greetingService.findAll(); + final List greetings = greetingService.findAll(); Assert.assertEquals("failure - expected 1 greeting", 1, greetings.size()); - final Greeting deletedGreeting = greetingService.findOne(id); + final Optional deletedGreetingOptional = greetingService.findOne(id); - Assert.assertNull("failure - expected greeting to be deleted", deletedGreeting); + Assert.assertTrue("failure - expected greeting to be deleted", deletedGreetingOptional.isEmpty()); } diff --git a/src/test/java/com/leanstacks/ws/util/BCryptPasswordEncoderUtil.java b/src/test/java/com/leanstacks/ws/util/BCryptPasswordEncoderUtil.java index f934556..65f9dd7 100644 --- a/src/test/java/com/leanstacks/ws/util/BCryptPasswordEncoderUtil.java +++ b/src/test/java/com/leanstacks/ws/util/BCryptPasswordEncoderUtil.java @@ -4,8 +4,6 @@ import java.io.OutputStreamWriter; import java.io.Writer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; /** @@ -24,11 +22,6 @@ */ public class BCryptPasswordEncoderUtil { - /** - * The Logger for this Class. - */ - private static final Logger logger = LoggerFactory.getLogger(BCryptPasswordEncoderUtil.class); - /** * The format for encoder messages. */ @@ -54,8 +47,9 @@ public String encode(final String clearText) { * Facilitates gathering user input and invoking the class behavior. * * @param args An array of command line input values. (not used) + * @throws IOException Thrown if performing IO operations fails. */ - public static void main(final String... args) { + public static void main(final String... args) throws IOException { final BCryptPasswordEncoderUtil encoderUtil = new BCryptPasswordEncoderUtil(); @@ -73,32 +67,28 @@ public static void main(final String... args) { * Writes a message to the console. * * @param str A String message value. + * @throws IOException Thrown if writing output fails. */ - private void write(final String str) { - - try { - if (writer == null) { - writer = new OutputStreamWriter(System.out); - } - writer.write(str); - } catch (IOException ioe) { - logger.error("Writer cannot write.", ioe); - System.exit(1); + private void write(final String str) throws IOException { + + if (writer == null) { + writer = new OutputStreamWriter(System.out); } + writer.write(str); + } /** * Closes all system resources and prepares for application termination. + * + * @throws IOException Thrown if closing the output stream fails. */ - private void close() { - try { - if (writer != null) { - writer.close(); - } - } catch (IOException ioe) { - logger.error("Problem closing resources.", ioe); - System.exit(1); + private void close() throws IOException { + + if (writer != null) { + writer.close(); } + } } diff --git a/src/test/java/com/leanstacks/ws/web/api/CreateGreetingDocTest.java b/src/test/java/com/leanstacks/ws/web/api/CreateGreetingDocTest.java new file mode 100644 index 0000000..c90d2ea --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/CreateGreetingDocTest.java @@ -0,0 +1,92 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class CreateGreetingDocTest extends AbstractDocTest { + + /** + * The HTTP request body content. + */ + private static final String REQUEST_BODY = "{ \"text\": \"Bonjour le monde!\" }"; + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for POST /api/greetings. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentCreateGreeting() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc + .perform(RestDocumentationRequestBuilders.post("/api/greetings").contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).content(REQUEST_BODY)) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andDo(MockMvcRestDocumentation.document("create-greeting", + PayloadDocumentation.relaxedRequestFields(PayloadDocumentation.fieldWithPath("text") + .description("The text.").type(JsonFieldType.STRING)), + PayloadDocumentation.relaxedResponseFields( + PayloadDocumentation + .fieldWithPath("id") + .description( + "The identifier. Used to reference specific greetings in API requests.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("referenceId") + .description("The supplementary identifier.").type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("text").description("The text.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("version").description("The entity version.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("createdBy").description("The entity creator.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("createdAt").description("The creation timestamp.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("updatedBy").description("The last modifier.") + .type(JsonFieldType.STRING).optional(), + PayloadDocumentation.fieldWithPath("updatedAt") + .description("The last modification timestamp.").type(JsonFieldType.STRING) + .optional()))) + .andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 201", 201, result.getResponse().getStatus()); + + } + +} diff --git a/src/test/java/com/leanstacks/ws/web/api/DeleteGreetingDocTest.java b/src/test/java/com/leanstacks/ws/web/api/DeleteGreetingDocTest.java new file mode 100644 index 0000000..478024d --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/DeleteGreetingDocTest.java @@ -0,0 +1,58 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class DeleteGreetingDocTest extends AbstractDocTest { + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for DELETE /api/greetings/{id}. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentDeleteGreeting() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc.perform(RestDocumentationRequestBuilders.delete("/api/greetings/{id}", 1)) + .andExpect(MockMvcResultMatchers.status().isNoContent()) + .andDo(MockMvcRestDocumentation.document("delete-greeting")).andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 204", 204, result.getResponse().getStatus()); + + } + +} diff --git a/src/test/java/com/leanstacks/ws/web/api/GetGreetingDocTest.java b/src/test/java/com/leanstacks/ws/web/api/GetGreetingDocTest.java new file mode 100644 index 0000000..306a658 --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/GetGreetingDocTest.java @@ -0,0 +1,87 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.restdocs.request.RequestDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class GetGreetingDocTest extends AbstractDocTest { + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for GET /api/greetings/{id}. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentGetGreeting() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc + .perform(RestDocumentationRequestBuilders + .get("/api/greetings/{id}", "0").accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(MockMvcRestDocumentation.document("get-greeting", + RequestDocumentation.pathParameters( + RequestDocumentation.parameterWithName("id").description("The greeting identifier.")), + PayloadDocumentation.relaxedResponseFields( + PayloadDocumentation.fieldWithPath("id") + .description( + "The identifier. Used to reference specific greetings in API requests.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("referenceId") + .description("The supplementary identifier.").type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("text").description("The text.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("version").description("The entity version.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("createdBy").description("The entity creator.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("createdAt").description("The creation timestamp.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("updatedBy").description("The last modifier.") + .type(JsonFieldType.STRING).optional(), + PayloadDocumentation.fieldWithPath("updatedAt") + .description("The last modification timestamp.").type(JsonFieldType.STRING) + .optional()))) + .andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 200", 200, result.getResponse().getStatus()); + + } + +} diff --git a/src/test/java/com/leanstacks/ws/web/api/GetGreetingsDocTest.java b/src/test/java/com/leanstacks/ws/web/api/GetGreetingsDocTest.java new file mode 100644 index 0000000..7b95b0c --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/GetGreetingsDocTest.java @@ -0,0 +1,75 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class GetGreetingsDocTest extends AbstractDocTest { + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for GET /api/greetings. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentGetGreetings() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc + .perform(RestDocumentationRequestBuilders.get("/api/greetings").accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(MockMvcRestDocumentation.document("get-greetings", + PayloadDocumentation.relaxedResponseFields( + PayloadDocumentation.fieldWithPath("[].id").description( + "The identifier. Used to reference specific greetings in API requests."), + PayloadDocumentation.fieldWithPath("[].referenceId") + .description("The supplementary identifier."), + PayloadDocumentation.fieldWithPath("[].text").description("The text."), + PayloadDocumentation.fieldWithPath("[].version").description("The entity version."), + PayloadDocumentation.fieldWithPath("[].createdBy").description("The entity creator."), + PayloadDocumentation.fieldWithPath("[].createdAt") + .description("The creation timestamp."), + PayloadDocumentation.fieldWithPath("[].updatedBy").description("The last modifier."), + PayloadDocumentation.fieldWithPath("[].updatedAt") + .description("The last modification timestamp.")))) + .andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 200", 200, result.getResponse().getStatus()); + + } + +} 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 9923d72..2fbde3e 100644 --- a/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java +++ b/src/test/java/com/leanstacks/ws/web/api/GreetingControllerTest.java @@ -6,7 +6,8 @@ import static org.mockito.Mockito.when; import java.util.ArrayList; -import java.util.Collection; +import java.util.List; +import java.util.Optional; import org.junit.Assert; import org.junit.Test; @@ -92,11 +93,16 @@ public void doAfterEachTest() { // perform test clean up } + /** + * Test fetch collection of Greetings. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testGetGreetings() throws Exception { // Create some test data - final Collection list = getEntityListStubData(); + final List list = getEntityListStubData(); // Stub the GreetingService.findAll method return value when(greetingService.findAll()).thenReturn(list); @@ -118,15 +124,20 @@ public void testGetGreetings() throws Exception { } + /** + * Test fetch a Greeting by identifier. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testGetGreeting() throws Exception { // Create some test data final Long id = Long.valueOf(1); - final Greeting entity = getEntityStubData(); + final Optional greetingOptional = Optional.of(getEntityStubData()); // Stub the GreetingService.findOne method return value - when(greetingService.findOne(id)).thenReturn(entity); + when(greetingService.findOne(id)).thenReturn(greetingOptional); // Perform the behavior being tested final MvcResult result = mvc @@ -145,6 +156,11 @@ public void testGetGreeting() throws Exception { Assert.assertTrue("failure - expected HTTP response body to have a value", !Strings.isNullOrEmpty(content)); } + /** + * Test fetch a Greeting with unknown identifier. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testGetGreetingNotFound() throws Exception { @@ -152,7 +168,7 @@ public void testGetGreetingNotFound() throws Exception { final Long id = Long.MAX_VALUE; // Stub the GreetingService.findOne method return value - when(greetingService.findOne(id)).thenReturn(null); + when(greetingService.findOne(id)).thenReturn(Optional.empty()); // Perform the behavior being tested final MvcResult result = mvc @@ -168,10 +184,15 @@ public void testGetGreetingNotFound() throws Exception { // 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", Strings.isNullOrEmpty(content)); + Assert.assertTrue("failure - expected HTTP response body to have a value", !Strings.isNullOrEmpty(content)); } + /** + * Test create a Greeting. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testCreateGreeting() throws Exception { @@ -208,6 +229,11 @@ public void testCreateGreeting() throws Exception { Assert.assertEquals("failure - expected text attribute match", entity.getText(), createdEntity.getText()); } + /** + * Test update a Greeting. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testUpdateGreeting() throws Exception { @@ -245,6 +271,11 @@ public void testUpdateGreeting() throws Exception { } + /** + * Test delete a Greeting. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testDeleteGreeting() throws Exception { @@ -267,15 +298,20 @@ public void testDeleteGreeting() throws Exception { } + /** + * Test sending email asynchronously. + * + * @throws Exception Thrown if mocking failure occurs. + */ @Test public void testSendGreetingAsync() throws Exception { // Create some test data final Long id = Long.valueOf(1); - final Greeting entity = getEntityStubData(); + final Optional greetingOptional = Optional.of(getEntityStubData()); // Stub the GreetingService.findOne method return value - when(greetingService.findOne(id)).thenReturn(entity); + when(greetingService.findOne(id)).thenReturn(greetingOptional); // Perform the behavior being tested final MvcResult result = mvc.perform( @@ -297,8 +333,8 @@ public void testSendGreetingAsync() throws Exception { Assert.assertTrue("failure - expected HTTP response body to have a value", !Strings.isNullOrEmpty(content)); } - private Collection getEntityListStubData() { - final Collection list = new ArrayList(); + private List getEntityListStubData() { + final List list = new ArrayList(); list.add(getEntityStubData()); return list; } diff --git a/src/test/java/com/leanstacks/ws/web/api/SendGreetingDocTest.java b/src/test/java/com/leanstacks/ws/web/api/SendGreetingDocTest.java new file mode 100644 index 0000000..f139b0f --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/SendGreetingDocTest.java @@ -0,0 +1,90 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.restdocs.request.RequestDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class SendGreetingDocTest extends AbstractDocTest { + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for POST /api/greetings/{id}/send. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentSendGreeting() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc + .perform(RestDocumentationRequestBuilders + .post("/api/greetings/{id}/send?wait=true", 1).accept(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(MockMvcRestDocumentation.document("send-greeting", + RequestDocumentation.pathParameters( + RequestDocumentation.parameterWithName("id").description("The greeting identifier.")), + RequestDocumentation.requestParameters(RequestDocumentation.parameterWithName("wait") + .description("Optional. Boolean. Wait for email to be sent.").optional()), + PayloadDocumentation.relaxedResponseFields( + PayloadDocumentation + .fieldWithPath("id") + .description( + "The identifier. Used to reference specific greetings in API requests.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("referenceId") + .description("The supplementary identifier.").type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("text").description("The text.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("version").description("The entity version.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("createdBy").description("The entity creator.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("createdAt").description("The creation timestamp.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("updatedBy").description("The last modifier.") + .type(JsonFieldType.STRING).optional(), + PayloadDocumentation.fieldWithPath("updatedAt") + .description("The last modification timestamp.").type(JsonFieldType.STRING) + .optional()))) + .andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 200", 200, result.getResponse().getStatus()); + + } + +} diff --git a/src/test/java/com/leanstacks/ws/web/api/UpdateGreetingDocTest.java b/src/test/java/com/leanstacks/ws/web/api/UpdateGreetingDocTest.java new file mode 100644 index 0000000..6b31a86 --- /dev/null +++ b/src/test/java/com/leanstacks/ws/web/api/UpdateGreetingDocTest.java @@ -0,0 +1,94 @@ +package com.leanstacks.ws.web.api; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.payload.PayloadDocumentation; +import org.springframework.restdocs.request.RequestDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import com.leanstacks.ws.AbstractDocTest; +import com.leanstacks.ws.RestControllerTest; + +/** + *

+ * Generate REST API documentation for the GreetingController. + *

+ *

+ * These tests utilize Spring's REST Docs Framework to generate API documentation. There is a separate test class + * responsible for unit testing functionality. + *

+ * + * @author Matt Warman + */ +@RunWith(SpringRunner.class) +@RestControllerTest +public class UpdateGreetingDocTest extends AbstractDocTest { + + /** + * The HTTP request body content. + */ + private static final String REQUEST_BODY = "{ \"text\": \"Bonjour le monde!\" }"; + + @Override + public void doBeforeEachTest() { + // perform test initialization + } + + @Override + public void doAfterEachTest() { + // perform test cleanup + } + + /** + * Generate API documentation for PUT /api/greetings/{id}. + * + * @throws Exception Thrown if documentation generation failure occurs. + */ + @Test + public void documentUpdateGreeting() throws Exception { + + // Generate API Documentation + final MvcResult result = this.mockMvc.perform(RestDocumentationRequestBuilders.put("/api/greetings/{id}", 1) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON).content(REQUEST_BODY)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andDo(MockMvcRestDocumentation.document("update-greeting", + RequestDocumentation.pathParameters( + RequestDocumentation.parameterWithName("id").description("The greeting identifier.")), + PayloadDocumentation.relaxedRequestFields(PayloadDocumentation.fieldWithPath("text") + .description("The text.").type(JsonFieldType.STRING)), + PayloadDocumentation.relaxedResponseFields( + PayloadDocumentation + .fieldWithPath("id") + .description( + "The identifier. Used to reference specific greetings in API requests.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("referenceId") + .description("The supplementary identifier.").type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("text").description("The text.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("version").description("The entity version.") + .type(JsonFieldType.NUMBER), + PayloadDocumentation.fieldWithPath("createdBy").description("The entity creator.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("createdAt").description("The creation timestamp.") + .type(JsonFieldType.STRING), + PayloadDocumentation.fieldWithPath("updatedBy").description("The last modifier.") + .type(JsonFieldType.STRING).optional(), + PayloadDocumentation.fieldWithPath("updatedAt") + .description("The last modification timestamp.").type(JsonFieldType.STRING) + .optional()))) + .andReturn(); + + // Perform a simple, standard JUnit assertion to satisfy PMD rule + Assert.assertEquals("failure - expected HTTP status 200", 200, result.getResponse().getStatus()); + + } + +}