├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── pom.xml └── src ├── main ├── doc │ └── frontend.png ├── java │ └── com │ │ └── cassiomolin │ │ └── example │ │ ├── Application.java │ │ ├── common │ │ └── api │ │ │ ├── config │ │ │ └── JerseyConfig.java │ │ │ ├── exceptionmapper │ │ │ ├── ConstraintViolationExceptionMapper.java │ │ │ ├── JsonMappingExceptionMapper.java │ │ │ └── JsonParseExceptionMapper.java │ │ │ ├── filter │ │ │ └── CorsFilter.java │ │ │ ├── model │ │ │ ├── ApiError.java │ │ │ └── ApiValidationError.java │ │ │ └── provider │ │ │ └── ObjectMapperProvider.java │ │ └── task │ │ ├── api │ │ ├── model │ │ │ ├── CreateTaskDetails.java │ │ │ ├── QueryTaskResult.java │ │ │ ├── UpdateTaskDetails.java │ │ │ ├── UpdateTaskStatusDetails.java │ │ │ └── mapper │ │ │ │ └── TaskMapper.java │ │ └── resource │ │ │ └── TaskResource.java │ │ ├── domain │ │ ├── Task.java │ │ └── TaskFilter.java │ │ ├── repository │ │ └── TaskRepository.java │ │ └── service │ │ └── TaskService.java ├── postman │ ├── tasks.postman_collection.json │ └── tasks.postman_environment.json └── resources │ ├── application.yaml │ └── data.sql └── test └── java └── com └── cassiomolin └── example └── task └── api └── TaskResourceTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ IDEA files 2 | .idea/ 3 | target/ 4 | tasks.iml 5 | 6 | # Compiled class file 7 | *.class 8 | 9 | # Log file 10 | *.log 11 | 12 | # Package Files # 13 | *.jar 14 | *.war 15 | *.ear 16 | *.zip 17 | *.tar.gz 18 | *.rar 19 | 20 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 21 | hs_err_pid* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Cassio Mazzochi Molin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample REST API for managing tasks using Spring Boot and Jersey 2 | 3 | [![Build Status](https://travis-ci.org/cassiomolin/tasks-rest-api.svg?branch=master)](https://travis-ci.org/cassiomolin/tasks-rest-api) 4 | [![MIT Licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/cassiomolin/tasks-rest-api/master/LICENSE.txt) 5 | 6 | Example of REST API using: 7 | 8 | - **Spring Boot:** Framework for creating standalone Java applications. 9 | - **Jersey:** JAX-RS reference implementation for creating RESTful web services in Java. 10 | - **Jackson:** JSON parser for Java. 11 | - **MapStruct:** Mapping framework for Java. 12 | - **Hibernate Validator:** Bean Validation implemetation to define and validate application constraints. 13 | - **REST Assured:** Testing framework for REST APIs. 14 | 15 | Besides the REST API, it features a client application built with **Angular** and **TypeScript**. See the [`tasks-client-angular`][client project] project for details. 16 | 17 | ## Building and running this application 18 | 19 | To build and run this application, follow these steps: 20 | 21 | 1. Open a command line window or terminal. 22 | 1. Navigate to the root directory of the project, where the `pom.xml` resides. 23 | 1. Compile the project: `mvn clean compile`. 24 | 1. Package the application: `mvn package`. 25 | 1. Change into the `target` directory: `cd target` 26 | 1. You should see a file with the following or a similar name: `tasks-1.0.jar`. 27 | 1. Execute the JAR: `java -jar tasks-1.0.jar`. 28 | 1. The REST API will be available at `http://localhost:8080/api`. 29 | 1. A JavaScript client application will be available at `http://localhost:8080`. 30 | 31 | When the application starts up, the database will be populated with some rows. 32 | 33 | ## Angular client application 34 | 35 | An **Angular** and **TypeScript** client application is shipped with the main application and it's available at `http://localhost:8080`: 36 | 37 | 38 | 39 | For better maintainability, client and server applications source code are kept in different repositories. During the build, the [client application artifacts][client releases] are downloaded, packed and released as part of the main application. 40 | 41 | For the client application source code, refer to the [`tasks-client-angular`][client project] project. 42 | 43 | ## REST API overview 44 | 45 | The application provides a REST API for managing tasks. See the [curl][] scripts below with the supported operations: 46 | 47 | ### Create a task 48 | 49 | ```bash 50 | curl -X POST \ 51 | 'http://localhost:8080/api/tasks' \ 52 | -H 'Content-Type: application/json' \ 53 | -d '{ 54 | "description": "Pay internet bill" 55 | }' 56 | ``` 57 | ### Get multiple tasks 58 | 59 | ```bash 60 | curl -X GET \ 61 | 'http://localhost:8080/api/tasks' \ 62 | -H 'Accept: application/json' 63 | ``` 64 | 65 | This endpoint supports the following query parameters: 66 | 67 | - `description` (string): Filter tasks by description (case-insensitive). 68 | - `completed` (boolean): Filter tasks by completed status. 69 | 70 | Filtering tasks by description: 71 | 72 | ```bash 73 | curl -X GET -G \ 74 | 'http://localhost:8080/api/tasks' \ 75 | -H 'Accept: application/json' \ 76 | -d 'description=avocado' 77 | ``` 78 | 79 | Filtering tasks by completed status: 80 | 81 | ```bash 82 | curl -X GET -G \ 83 | 'http://localhost:8080/api/tasks' \ 84 | -H 'Accept: application/json' \ 85 | -d 'completed=true' 86 | ``` 87 | 88 | Filtering tasks by description and by completed status: 89 | 90 | ```bash 91 | curl -X GET -G \ 92 | 'http://localhost:8080/api/tasks' \ 93 | -H 'Accept: application/json' \ 94 | -d 'description=karate' \ 95 | -d 'completed=true' 96 | ``` 97 | 98 | ### Get a task by id 99 | 100 | ```bash 101 | curl -X GET \ 102 | 'http://localhost:8080/api/tasks/5' \ 103 | -H 'Accept: application/json' 104 | ``` 105 | 106 | ### Update a task 107 | 108 | ```bash 109 | curl -X PUT \ 110 | 'http://localhost:8080/api/tasks/5' \ 111 | -H 'Content-Type: application/json' \ 112 | -d '{ 113 | "description": "Pay electricity bill", 114 | "completed": false 115 | }' 116 | ``` 117 | 118 | ### Update a task completed status 119 | 120 | ```bash 121 | curl -X PUT \ 122 | 'http://localhost:8080/api/tasks/5/completed' \ 123 | -H 'Content-Type: application/json' \ 124 | -d '{ 125 | "value": true 126 | }' 127 | ``` 128 | 129 | ### Delete a task by id 130 | 131 | ```bash 132 | curl -X DELETE \ 133 | 'http://localhost:8080/api/tasks/5' 134 | ``` 135 | 136 | ### Delete multiple tasks 137 | 138 | ```bash 139 | curl -X DELETE \ 140 | 'http://localhost:8080/api/tasks' 141 | ``` 142 | 143 | This endpoint supports the following query parameter: 144 | 145 | - `completed` (boolean): Delete tasks by completed status. 146 | 147 | And it can be used as following: 148 | 149 | ```bash 150 | curl -X DELETE -G \ 151 | 'http://localhost:8080/api/tasks' \ 152 | -d 'completed=true' 153 | ``` 154 | 155 | ## Targeting the REST API with Postman 156 | 157 | Alternatively to [curl][], you can use [Postman][] to target the REST API. The Postman collection files are available in the [`src/main/postman`](src/main/postman) directory. 158 | 159 | [Postman]: https://www.getpostman.com/ 160 | [client project]: https://github.com/cassiomolin/tasks-client-angular 161 | [client releases]: https://github.com/cassiomolin/tasks-client-angular/releases 162 | [curl]: https://curl.haxx.se/ 163 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.cassiomolin.example 8 | tasks 9 | 1.0 10 | 11 | 12 | 13 | UTF-8 14 | 1.8 15 | 1.8 16 | 17 | 1.5.6.RELEASE 18 | 1.1.0.Final 19 | 3.0.3 20 | 2.8.8 21 | 1.0-alpha-2 22 | 23 | 24 | 25 | 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-dependencies 30 | ${spring-boot.version} 31 | pom 32 | import 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-web 43 | 44 | 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-starter-jersey 49 | 50 | 51 | 52 | 53 | org.springframework.boot 54 | spring-boot-starter-data-jpa 55 | 56 | 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-test 61 | test 62 | 63 | 64 | 65 | 66 | com.fasterxml.jackson.module 67 | jackson-module-parameter-names 68 | ${jackson.version} 69 | 70 | 71 | com.fasterxml.jackson.datatype 72 | jackson-datatype-jdk8 73 | ${jackson.version} 74 | 75 | 76 | com.fasterxml.jackson.datatype 77 | jackson-datatype-jsr310 78 | ${jackson.version} 79 | 80 | 81 | 82 | 83 | com.h2database 84 | h2 85 | 86 | 87 | 88 | 89 | org.mapstruct 90 | mapstruct-jdk8 91 | ${mapstruct.version} 92 | 93 | 94 | org.mapstruct 95 | mapstruct-processor 96 | ${mapstruct.version} 97 | true 98 | 99 | 100 | 101 | 102 | io.rest-assured 103 | rest-assured 104 | ${rest-assured.version} 105 | test 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | org.apache.maven.plugins 115 | maven-compiler-plugin 116 | 3.5.1 117 | 118 | 119 | 120 | org.mapstruct 121 | mapstruct-processor 122 | ${mapstruct.version} 123 | 124 | 125 | 126 | -Amapstruct.suppressGeneratorTimestamp=true 127 | -Amapstruct.suppressGeneratorVersionInfoComment=true 128 | -Amapstruct.defaultComponentModel=spring 129 | 130 | 131 | 132 | 133 | 134 | org.springframework.boot 135 | spring-boot-maven-plugin 136 | ${spring-boot.version} 137 | 138 | 139 | 140 | repackage 141 | 142 | 143 | 144 | 145 | 146 | 147 | maven-antrun-plugin 148 | 1.8 149 | 150 | 151 | validate 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | run 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /src/main/doc/frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cassiomolin/tasks-rest-api/c470ddcd2a3a43629fff7f6d26b8106084b9614c/src/main/doc/frontend.png -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/Application.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | /** 7 | * Spring Boot application entry point. 8 | * 9 | * @author cassiomolin 10 | */ 11 | @SpringBootApplication 12 | public class Application { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(Application.class, args); 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/config/JerseyConfig.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.config; 2 | 3 | import com.cassiomolin.example.common.api.exceptionmapper.ConstraintViolationExceptionMapper; 4 | import com.cassiomolin.example.common.api.exceptionmapper.JsonMappingExceptionMapper; 5 | import com.cassiomolin.example.common.api.exceptionmapper.JsonParseExceptionMapper; 6 | import com.cassiomolin.example.common.api.filter.CorsFilter; 7 | import com.cassiomolin.example.common.api.provider.ObjectMapperProvider; 8 | import com.cassiomolin.example.task.api.resource.TaskResource; 9 | import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider; 10 | import org.glassfish.jersey.server.ResourceConfig; 11 | import org.springframework.stereotype.Component; 12 | 13 | import javax.ws.rs.ApplicationPath; 14 | 15 | /** 16 | * Jersey configuration class. 17 | * 18 | * @author cassiomolin 19 | */ 20 | @Component 21 | @ApplicationPath("api") 22 | public class JerseyConfig extends ResourceConfig { 23 | 24 | public JerseyConfig() { 25 | registerResources(); 26 | registerProviders(); 27 | } 28 | 29 | private void registerResources() { 30 | register(TaskResource.class); 31 | } 32 | 33 | private void registerProviders() { 34 | register(CorsFilter.class); 35 | register(JacksonJaxbJsonProvider.class); 36 | register(ConstraintViolationExceptionMapper.class); 37 | register(JsonMappingExceptionMapper.class); 38 | register(JsonParseExceptionMapper.class); 39 | register(ObjectMapperProvider.class); 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/exceptionmapper/ConstraintViolationExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.exceptionmapper; 2 | 3 | import com.cassiomolin.example.common.api.model.ApiError; 4 | import com.cassiomolin.example.common.api.model.ApiValidationError; 5 | import com.cassiomolin.example.common.api.model.ApiValidationError.ValidationError; 6 | import com.fasterxml.jackson.databind.BeanDescription; 7 | import com.fasterxml.jackson.databind.JavaType; 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; 10 | 11 | import javax.validation.ConstraintViolation; 12 | import javax.validation.ConstraintViolationException; 13 | import javax.validation.ElementKind; 14 | import javax.validation.Path; 15 | import javax.ws.rs.*; 16 | import javax.ws.rs.core.Context; 17 | import javax.ws.rs.core.MediaType; 18 | import javax.ws.rs.core.Response; 19 | import javax.ws.rs.core.UriInfo; 20 | import javax.ws.rs.ext.ContextResolver; 21 | import javax.ws.rs.ext.ExceptionMapper; 22 | import javax.ws.rs.ext.Provider; 23 | import javax.ws.rs.ext.Providers; 24 | import java.lang.annotation.Annotation; 25 | import java.lang.reflect.Field; 26 | import java.lang.reflect.Method; 27 | import java.util.ArrayList; 28 | import java.util.Arrays; 29 | import java.util.List; 30 | import java.util.Optional; 31 | import java.util.stream.Collectors; 32 | import java.util.stream.StreamSupport; 33 | 34 | /** 35 | * Component that maps a {@link ConstraintViolationException} to a HTTP response. 36 | * 37 | * @author cassiomolin 38 | */ 39 | @Provider 40 | public class ConstraintViolationExceptionMapper implements ExceptionMapper { 41 | 42 | @Context 43 | private UriInfo uriInfo; 44 | 45 | @Context 46 | private Providers providers; 47 | 48 | private static final String REQUEST_ENTITY = "requestEntity"; 49 | private static final String REQUEST_ENTITY_PROPERTY = "requestEntityProperty"; 50 | private static final String REQUEST_QUERY_PARAMETER = "requestQueryParameter"; 51 | private static final String REQUEST_PATH_PARAMETER = "requestPathParameter"; 52 | private static final String REQUEST_HEADER_PARAMETER = "requestHeaderParameter"; 53 | private static final String REQUEST_COOKIE_PARAMETER = "requestCookieParameter"; 54 | private static final String REQUEST_FORM_PARAMETER = "requestFormParameter"; 55 | private static final String REQUEST_MATRIX_PARAMETER = "requestMatrixParameter"; 56 | 57 | @Override 58 | public Response toResponse(ConstraintViolationException exception) { 59 | ApiError error = mapToApiError(exception); 60 | return Response.status(Response.Status.BAD_REQUEST).entity(error).type(MediaType.APPLICATION_JSON).build(); 61 | } 62 | 63 | /** 64 | * Map a {@link ConstraintViolationException} to an {@link ApiError}. 65 | * 66 | * @param exception 67 | * @return 68 | */ 69 | private ApiError mapToApiError(ConstraintViolationException exception) { 70 | 71 | ApiValidationError error = new ApiValidationError(); 72 | error.setError("Validation error"); 73 | error.setMessage("Request cannot be processed due to validation errors"); 74 | error.setPath(uriInfo.getAbsolutePath().getPath()); 75 | 76 | List validationErrors = exception.getConstraintViolations().stream().map(constraintViolation -> { 77 | 78 | Path.Node leafNode = getLeafNode(constraintViolation.getPropertyPath()).get(); 79 | 80 | switch (leafNode.getKind()) { 81 | 82 | case PROPERTY: 83 | return handleInvalidProperty(constraintViolation); 84 | 85 | case PARAMETER: 86 | return handleInvalidParameter(constraintViolation); 87 | 88 | case BEAN: 89 | return handleInvalidBean(constraintViolation); 90 | 91 | default: 92 | return handleUnknownSource(constraintViolation); 93 | } 94 | 95 | }).collect(Collectors.toList()); 96 | error.setValidationErrors(validationErrors); 97 | 98 | return error; 99 | } 100 | 101 | /** 102 | * Handle an invalid property. Can be: 103 | *

104 | * 1. Invalid request parameter (annotated bean param field or annotated resource class field) 105 | * 2. Invalid request entity property (annotated bean param field) 106 | * 107 | * @param constraintViolation 108 | */ 109 | private ValidationError handleInvalidProperty(ConstraintViolation constraintViolation) { 110 | 111 | Path.Node leafNode = getLeafNode(constraintViolation.getPropertyPath()).get(); 112 | Class beanClass = constraintViolation.getLeafBean().getClass(); 113 | 114 | // Can be an invalid request parameter (annotated bean param field or annotated resource class field) 115 | Optional optionalField = getField(leafNode.getName(), beanClass); 116 | if (optionalField.isPresent()) { 117 | Optional optionalParameterDetails = getParameterDetails(optionalField.get().getAnnotations()); 118 | if (optionalParameterDetails.isPresent()) { 119 | return createErrorForParameter(optionalParameterDetails.get(), constraintViolation); 120 | } 121 | } 122 | 123 | // Get Jackson ObjectMapper 124 | ContextResolver resolver = providers.getContextResolver(ObjectMapper.class, MediaType.WILDCARD_TYPE); 125 | ObjectMapper mapper = resolver.getContext(ObjectMapper.class); 126 | 127 | // Can be an invalid request entity property (annotated bean param field) 128 | Optional optionalJsonProperty = getJsonPropertyName(mapper, beanClass, leafNode.getName()); 129 | if (optionalJsonProperty.isPresent()) { 130 | ValidationError error = new ValidationError(); 131 | error.setType(REQUEST_ENTITY_PROPERTY); 132 | error.setName(optionalJsonProperty.get()); 133 | error.setMessage(constraintViolation.getMessage()); 134 | return error; 135 | } 136 | 137 | return handleUnknownSource(constraintViolation); 138 | } 139 | 140 | /** 141 | * Handle an invalid parameter. Can be: 142 | *

143 | * 1. Invalid request parameter (annotated method parameter) 144 | * 2. Invalid request entity (annotated method parameter) 145 | * 146 | * @param constraintViolation 147 | */ 148 | private ValidationError handleInvalidParameter(ConstraintViolation constraintViolation) { 149 | 150 | List nodes = new ArrayList<>(); 151 | constraintViolation.getPropertyPath().iterator().forEachRemaining(nodes::add); 152 | 153 | Path.Node parent = nodes.get(nodes.size() - 2); 154 | Path.Node child = nodes.get(nodes.size() - 1); 155 | 156 | if (ElementKind.METHOD == parent.getKind()) { 157 | 158 | Path.MethodNode methodNode = parent.as(Path.MethodNode.class); 159 | Path.ParameterNode parameterNode = child.as(Path.ParameterNode.class); 160 | 161 | try { 162 | 163 | // Can be an invalid request parameter (annotated method parameter) 164 | Class beanClass = constraintViolation.getLeafBean().getClass(); 165 | Method method = beanClass.getMethod(methodNode.getName(), methodNode.getParameterTypes().stream().toArray(Class[]::new)); 166 | Annotation[] annotations = method.getParameterAnnotations()[parameterNode.getParameterIndex()]; 167 | Optional optionalParameterDetails = getParameterDetails(annotations); 168 | if (optionalParameterDetails.isPresent()) { 169 | return createErrorForParameter(optionalParameterDetails.get(), constraintViolation); 170 | } 171 | 172 | } catch (NoSuchMethodException e) { 173 | e.printStackTrace(); 174 | } 175 | 176 | // Assumes that the request entity is invalid (annotated method parameter) 177 | ValidationError error = new ValidationError(); 178 | error.setType(REQUEST_ENTITY); 179 | error.setMessage(constraintViolation.getMessage()); 180 | return error; 181 | } 182 | 183 | return handleUnknownSource(constraintViolation); 184 | } 185 | 186 | /** 187 | * Handle an invalid bean. Can be: 188 | *

189 | * 1. Invalid request bean (annotated bean class) 190 | * 191 | * @param constraintViolation 192 | */ 193 | private ValidationError handleInvalidBean(ConstraintViolation constraintViolation) { 194 | 195 | ValidationError error = new ValidationError(); 196 | error.setType("entity"); 197 | error.setMessage(constraintViolation.getMessage()); 198 | 199 | return error; 200 | } 201 | 202 | 203 | /** 204 | * Handle other error situations. Works as as fallback. 205 | * 206 | * @param constraintViolation 207 | * @return 208 | */ 209 | private ValidationError handleUnknownSource(ConstraintViolation constraintViolation) { 210 | ValidationError error = new ValidationError(); 211 | error.setName(constraintViolation.getPropertyPath().toString()); 212 | error.setMessage(constraintViolation.getMessage()); 213 | return error; 214 | } 215 | 216 | /** 217 | * Create error for parameter. 218 | * 219 | * @param parameterDetails 220 | * @param constraintViolation 221 | * @return 222 | */ 223 | private ValidationError createErrorForParameter(ParameterDetails parameterDetails, ConstraintViolation constraintViolation) { 224 | ValidationError error = new ValidationError(); 225 | error.setType(parameterDetails.getType()); 226 | error.setName(parameterDetails.getName()); 227 | error.setMessage(constraintViolation.getMessage()); 228 | return error; 229 | } 230 | 231 | /** 232 | * Get the leaf node. 233 | * 234 | * @param path 235 | * @return 236 | */ 237 | private Optional getLeafNode(Path path) { 238 | return StreamSupport.stream(path.spliterator(), false).reduce((a, b) -> b); 239 | } 240 | 241 | /** 242 | * Get field from class. 243 | * 244 | * @param fieldName 245 | * @param beanClass 246 | * @return 247 | */ 248 | private Optional getField(String fieldName, Class beanClass) { 249 | return Arrays.stream(beanClass.getDeclaredFields()) 250 | .filter(field -> field.getName().equals(fieldName)) 251 | .findFirst(); 252 | } 253 | 254 | /** 255 | * Get parameter details from JAX-RS annotation. 256 | * 257 | * @param annotations 258 | * @return 259 | */ 260 | private Optional getParameterDetails(Annotation[] annotations) { 261 | 262 | for (Annotation annotation : annotations) { 263 | Optional optionalParameterDetails = getParameterDetails(annotation); 264 | if (optionalParameterDetails.isPresent()) { 265 | return optionalParameterDetails; 266 | } 267 | } 268 | 269 | return Optional.empty(); 270 | } 271 | 272 | /** 273 | * Get the parameter details from JAX-RS annotation. 274 | * 275 | * @param annotation 276 | * @return 277 | */ 278 | private Optional getParameterDetails(Annotation annotation) { 279 | 280 | ParameterDetails parameterDetails = new ParameterDetails(); 281 | 282 | if (annotation instanceof QueryParam) { 283 | parameterDetails.setType(REQUEST_QUERY_PARAMETER); 284 | parameterDetails.setName(((QueryParam) annotation).value()); 285 | return Optional.of(parameterDetails); 286 | 287 | } else if (annotation instanceof PathParam) { 288 | parameterDetails.setType(REQUEST_PATH_PARAMETER); 289 | parameterDetails.setName(((PathParam) annotation).value()); 290 | return Optional.of(parameterDetails); 291 | 292 | } else if (annotation instanceof HeaderParam) { 293 | parameterDetails.setType(REQUEST_HEADER_PARAMETER); 294 | parameterDetails.setName(((HeaderParam) annotation).value()); 295 | return Optional.of(parameterDetails); 296 | 297 | } else if (annotation instanceof CookieParam) { 298 | parameterDetails.setType(REQUEST_COOKIE_PARAMETER); 299 | parameterDetails.setName(((CookieParam) annotation).value()); 300 | return Optional.of(parameterDetails); 301 | 302 | } else if (annotation instanceof FormParam) { 303 | parameterDetails.setType(REQUEST_FORM_PARAMETER); 304 | parameterDetails.setName(((FormParam) annotation).value()); 305 | return Optional.of(parameterDetails); 306 | 307 | } else if (annotation instanceof MatrixParam) { 308 | parameterDetails.setType(REQUEST_MATRIX_PARAMETER); 309 | parameterDetails.setName(((MatrixParam) annotation).value()); 310 | return Optional.of(parameterDetails); 311 | } 312 | 313 | return Optional.empty(); 314 | } 315 | 316 | /** 317 | * Get the JSON property name. 318 | * 319 | * @param mapper 320 | * @param beanClass 321 | * @param nodeName 322 | * @return 323 | */ 324 | private Optional getJsonPropertyName(ObjectMapper mapper, Class beanClass, String nodeName) { 325 | 326 | JavaType javaType = mapper.getTypeFactory().constructType(beanClass); 327 | BeanDescription introspection = mapper.getSerializationConfig().introspect(javaType); 328 | List properties = introspection.findProperties(); 329 | 330 | return properties.stream() 331 | .filter(propertyDefinition -> nodeName.equals(propertyDefinition.getField().getName())) 332 | .map(BeanPropertyDefinition::getName) 333 | .findFirst(); 334 | } 335 | 336 | /** 337 | * Class to hold JAX-RS parameter details. 338 | */ 339 | private static class ParameterDetails { 340 | 341 | private String type; 342 | 343 | private String name; 344 | 345 | public ParameterDetails() { 346 | 347 | } 348 | 349 | public String getType() { 350 | return type; 351 | } 352 | 353 | public void setType(String type) { 354 | this.type = type; 355 | } 356 | 357 | public String getName() { 358 | return name; 359 | } 360 | 361 | public void setName(String name) { 362 | this.name = name; 363 | } 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/exceptionmapper/JsonMappingExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.exceptionmapper; 2 | 3 | import com.cassiomolin.example.common.api.model.ApiError; 4 | import com.fasterxml.jackson.databind.JsonMappingException; 5 | 6 | import javax.ws.rs.core.Context; 7 | import javax.ws.rs.core.MediaType; 8 | import javax.ws.rs.core.Response; 9 | import javax.ws.rs.core.Response.Status; 10 | import javax.ws.rs.core.UriInfo; 11 | import javax.ws.rs.ext.ExceptionMapper; 12 | import javax.ws.rs.ext.Provider; 13 | import java.time.Instant; 14 | 15 | /** 16 | * Component that maps a {@link JsonMappingException} to a HTTP response. 17 | * 18 | * @author cassiomolin 19 | */ 20 | @Provider 21 | public class JsonMappingExceptionMapper implements ExceptionMapper { 22 | 23 | @Context 24 | private UriInfo uriInfo; 25 | 26 | @Override 27 | public Response toResponse(JsonMappingException exception) { 28 | 29 | ApiError error = new ApiError(); 30 | error.setTimestamp(Instant.now().toEpochMilli()); 31 | error.setStatus(Status.BAD_REQUEST.getStatusCode()); 32 | error.setError(Status.BAD_REQUEST.getReasonPhrase()); 33 | error.setMessage("Request JSON cannot be parsed"); 34 | error.setPath(uriInfo.getAbsolutePath().getPath()); 35 | 36 | return Response.status(Status.BAD_REQUEST).entity(error).type(MediaType.APPLICATION_JSON).build(); 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/exceptionmapper/JsonParseExceptionMapper.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.exceptionmapper; 2 | 3 | import com.cassiomolin.example.common.api.model.ApiError; 4 | import com.fasterxml.jackson.core.JsonParseException; 5 | 6 | import javax.ws.rs.core.Context; 7 | import javax.ws.rs.core.MediaType; 8 | import javax.ws.rs.core.Response; 9 | import javax.ws.rs.core.Response.Status; 10 | import javax.ws.rs.core.UriInfo; 11 | import javax.ws.rs.ext.ExceptionMapper; 12 | import javax.ws.rs.ext.Provider; 13 | import java.time.Instant; 14 | 15 | /** 16 | * Component that maps a {@link JsonParseException} to a HTTP response. 17 | * 18 | * @author cassiomolin 19 | */ 20 | @Provider 21 | public class JsonParseExceptionMapper implements ExceptionMapper { 22 | 23 | @Context 24 | private UriInfo uriInfo; 25 | 26 | @Override 27 | public Response toResponse(JsonParseException exception) { 28 | 29 | ApiError error = new ApiError(); 30 | error.setTimestamp(Instant.now().toEpochMilli()); 31 | error.setStatus(Status.BAD_REQUEST.getStatusCode()); 32 | error.setError(Status.BAD_REQUEST.getReasonPhrase()); 33 | error.setMessage("Request JSON cannot be parsed"); 34 | error.setPath(uriInfo.getAbsolutePath().getPath()); 35 | 36 | return Response.status(Status.BAD_REQUEST).entity(error).type(MediaType.APPLICATION_JSON).build(); 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/filter/CorsFilter.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.filter; 2 | 3 | import javax.ws.rs.container.ContainerRequestContext; 4 | import javax.ws.rs.container.ContainerResponseContext; 5 | import javax.ws.rs.container.ContainerResponseFilter; 6 | import javax.ws.rs.ext.Provider; 7 | 8 | /** 9 | * Filter that adds CORS headers to the HTTP response. 10 | * 11 | * @author cassiomolin 12 | */ 13 | @Provider 14 | public class CorsFilter implements ContainerResponseFilter { 15 | 16 | @Override 17 | public void filter(ContainerRequestContext request, ContainerResponseContext response) { 18 | 19 | response.getHeaders().add("Access-Control-Allow-Origin", "*"); 20 | response.getHeaders().add("Access-Control-Allow-Credentials", "true"); 21 | response.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD"); 22 | response.getHeaders().add("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Location"); 23 | response.getHeaders().add("Access-Control-Expose-Headers", "Content-Type, Location"); 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/model/ApiError.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | 5 | /** 6 | * Model that represents an API error. 7 | * 8 | * @author cassiomolin 9 | */ 10 | @JsonInclude(JsonInclude.Include.NON_NULL) 11 | public class ApiError { 12 | 13 | private Long timestamp; 14 | 15 | private Integer status; 16 | 17 | private String error; 18 | 19 | private String message; 20 | 21 | private String path; 22 | 23 | public ApiError() { 24 | 25 | } 26 | 27 | public Long getTimestamp() { 28 | return timestamp; 29 | } 30 | 31 | public void setTimestamp(Long timestamp) { 32 | this.timestamp = timestamp; 33 | } 34 | 35 | public Integer getStatus() { 36 | return status; 37 | } 38 | 39 | public void setStatus(Integer status) { 40 | this.status = status; 41 | } 42 | 43 | public String getError() { 44 | return error; 45 | } 46 | 47 | public void setError(String error) { 48 | this.error = error; 49 | } 50 | 51 | public String getMessage() { 52 | return message; 53 | } 54 | 55 | public void setMessage(String message) { 56 | this.message = message; 57 | } 58 | 59 | public String getPath() { 60 | return path; 61 | } 62 | 63 | public void setPath(String path) { 64 | this.path = path; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/model/ApiValidationError.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Model that represents an API validation error. 9 | * 10 | * @author cassiomolin 11 | */ 12 | @JsonInclude(JsonInclude.Include.NON_NULL) 13 | public class ApiValidationError extends ApiError { 14 | 15 | private List validationErrors; 16 | 17 | public ApiValidationError() { 18 | 19 | } 20 | 21 | public List getValidationErrors() { 22 | return validationErrors; 23 | } 24 | 25 | public void setValidationErrors(List validationErrors) { 26 | this.validationErrors = validationErrors; 27 | } 28 | 29 | /** 30 | * Model that represents a validation error. 31 | * 32 | * @author cassiomolin 33 | */ 34 | @JsonInclude(JsonInclude.Include.NON_NULL) 35 | public static class ValidationError { 36 | 37 | private String type; 38 | 39 | private String name; 40 | 41 | private String message; 42 | 43 | public ValidationError() { 44 | 45 | } 46 | 47 | public String getType() { 48 | return type; 49 | } 50 | 51 | public void setType(String type) { 52 | this.type = type; 53 | } 54 | 55 | public String getName() { 56 | return name; 57 | } 58 | 59 | public void setName(String name) { 60 | this.name = name; 61 | } 62 | 63 | public String getMessage() { 64 | return message; 65 | } 66 | 67 | public void setMessage(String message) { 68 | this.message = message; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/common/api/provider/ObjectMapperProvider.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.common.api.provider; 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.fasterxml.jackson.databind.SerializationFeature; 6 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; 7 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 8 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; 9 | 10 | import javax.ws.rs.ext.ContextResolver; 11 | import javax.ws.rs.ext.Provider; 12 | 13 | /** 14 | * Provider for {@link ObjectMapper}. 15 | * 16 | * @author cassiomolin 17 | */ 18 | @Provider 19 | public class ObjectMapperProvider implements ContextResolver { 20 | 21 | private final ObjectMapper mapper; 22 | 23 | public ObjectMapperProvider() { 24 | mapper = createObjectMapper(); 25 | } 26 | 27 | @Override 28 | public ObjectMapper getContext(Class type) { 29 | return mapper; 30 | } 31 | 32 | private static ObjectMapper createObjectMapper() { 33 | 34 | ObjectMapper mapper = new ObjectMapper(); 35 | 36 | mapper.registerModule(new ParameterNamesModule()); 37 | mapper.registerModule(new Jdk8Module()); 38 | mapper.registerModule(new JavaTimeModule()); 39 | 40 | mapper.enable(SerializationFeature.INDENT_OUTPUT); 41 | mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); 42 | mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); 43 | 44 | mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); 45 | mapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT); 46 | 47 | return mapper; 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/model/CreateTaskDetails.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.model; 2 | 3 | import org.hibernate.validator.constraints.NotBlank; 4 | 5 | /** 6 | * API model that holds details for creating a task. 7 | * 8 | * @author cassiomolin 9 | */ 10 | public class CreateTaskDetails { 11 | 12 | @NotBlank 13 | private String description; 14 | 15 | public CreateTaskDetails() { 16 | 17 | } 18 | 19 | public String getDescription() { 20 | return description; 21 | } 22 | 23 | public void setDescription(String description) { 24 | this.description = description; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/model/QueryTaskResult.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.model; 2 | 3 | import java.time.ZonedDateTime; 4 | 5 | /** 6 | * API model for returning a task query result. 7 | * 8 | * @author cassiomolin 9 | */ 10 | public class QueryTaskResult { 11 | 12 | private Long id; 13 | 14 | private String description; 15 | 16 | private Boolean completed; 17 | 18 | private ZonedDateTime createdDate; 19 | 20 | public QueryTaskResult() { 21 | 22 | } 23 | 24 | public Long getId() { 25 | return id; 26 | } 27 | 28 | public void setId(Long id) { 29 | this.id = id; 30 | } 31 | 32 | public String getDescription() { 33 | return description; 34 | } 35 | 36 | public void setDescription(String description) { 37 | this.description = description; 38 | } 39 | 40 | public Boolean getCompleted() { 41 | return completed; 42 | } 43 | 44 | public void setCompleted(Boolean completed) { 45 | this.completed = completed; 46 | } 47 | 48 | public ZonedDateTime getCreatedDate() { 49 | return createdDate; 50 | } 51 | 52 | public void setCreatedDate(ZonedDateTime createdDate) { 53 | this.createdDate = createdDate; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/model/UpdateTaskDetails.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.model; 2 | 3 | import org.hibernate.validator.constraints.NotBlank; 4 | 5 | import javax.validation.constraints.NotNull; 6 | 7 | /** 8 | * API model that holds details for updating a task. 9 | * 10 | * @author cassiomolin 11 | */ 12 | public class UpdateTaskDetails { 13 | 14 | @NotBlank 15 | private String description; 16 | 17 | @NotNull 18 | private Boolean completed; 19 | 20 | public UpdateTaskDetails() { 21 | 22 | } 23 | 24 | public String getDescription() { 25 | return description; 26 | } 27 | 28 | public void setDescription(String description) { 29 | this.description = description; 30 | } 31 | 32 | public Boolean getCompleted() { 33 | return completed; 34 | } 35 | 36 | public void setCompleted(Boolean completed) { 37 | this.completed = completed; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/model/UpdateTaskStatusDetails.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.model; 2 | 3 | import javax.validation.constraints.NotNull; 4 | 5 | /** 6 | * API model that holds details for updating a task status. 7 | * 8 | * @author cassiomolin 9 | */ 10 | public class UpdateTaskStatusDetails { 11 | 12 | @NotNull 13 | private Boolean value; 14 | 15 | public UpdateTaskStatusDetails() { 16 | 17 | } 18 | 19 | public Boolean getValue() { 20 | return value; 21 | } 22 | 23 | public void setValue(Boolean value) { 24 | this.value = value; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/model/mapper/TaskMapper.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.model.mapper; 2 | 3 | 4 | import com.cassiomolin.example.task.api.model.CreateTaskDetails; 5 | import com.cassiomolin.example.task.api.model.QueryTaskResult; 6 | import com.cassiomolin.example.task.api.model.UpdateTaskDetails; 7 | import com.cassiomolin.example.task.api.model.UpdateTaskStatusDetails; 8 | import com.cassiomolin.example.task.domain.Task; 9 | import org.mapstruct.Mapper; 10 | import org.mapstruct.Mapping; 11 | import org.mapstruct.MappingTarget; 12 | import org.mapstruct.ReportingPolicy; 13 | 14 | import java.util.List; 15 | 16 | /** 17 | * Component that maps a {@link Task} domain model to API models and vice versa. 18 | * 19 | * @author cassiomolin 20 | */ 21 | @Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE) 22 | public interface TaskMapper { 23 | 24 | Task toTask(CreateTaskDetails createTaskDetails); 25 | 26 | QueryTaskResult toQueryTaskResult(Task task); 27 | 28 | List toQueryTaskResults(List tasks); 29 | 30 | void updateTask(UpdateTaskDetails updateTaskDetails, @MappingTarget Task task); 31 | 32 | @Mapping(source = "value", target = "completed") 33 | void updateTask(UpdateTaskStatusDetails updateTaskStatusDetails, @MappingTarget Task task); 34 | } -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/api/resource/TaskResource.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api.resource; 2 | 3 | 4 | import com.cassiomolin.example.task.api.model.CreateTaskDetails; 5 | import com.cassiomolin.example.task.api.model.QueryTaskResult; 6 | import com.cassiomolin.example.task.api.model.UpdateTaskDetails; 7 | import com.cassiomolin.example.task.api.model.UpdateTaskStatusDetails; 8 | import com.cassiomolin.example.task.api.model.mapper.TaskMapper; 9 | import com.cassiomolin.example.task.domain.Task; 10 | import com.cassiomolin.example.task.domain.TaskFilter; 11 | import com.cassiomolin.example.task.service.TaskService; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.stereotype.Component; 14 | 15 | import javax.validation.Valid; 16 | import javax.validation.constraints.NotNull; 17 | import javax.ws.rs.*; 18 | import javax.ws.rs.core.Context; 19 | import javax.ws.rs.core.MediaType; 20 | import javax.ws.rs.core.Response; 21 | import javax.ws.rs.core.UriInfo; 22 | import java.net.URI; 23 | import java.util.List; 24 | 25 | /** 26 | * Component that exposes REST API endpoints for {@link Task}s. 27 | * 28 | * @author cassiomolin 29 | */ 30 | @Component 31 | @Path("tasks") 32 | @Consumes(MediaType.APPLICATION_JSON) 33 | @Produces(MediaType.APPLICATION_JSON) 34 | public class TaskResource { 35 | 36 | @Context 37 | private UriInfo uriInfo; 38 | 39 | @Autowired 40 | private TaskMapper taskMapper; 41 | 42 | @Autowired 43 | private TaskService taskService; 44 | 45 | @POST 46 | public Response createTask(@Valid @NotNull CreateTaskDetails createTaskDetails) { 47 | Task task = taskMapper.toTask(createTaskDetails); 48 | task = taskService.createTask(task); 49 | URI uri = uriInfo.getAbsolutePathBuilder().path(task.getId().toString()).build(); 50 | return Response.created(uri).build(); 51 | } 52 | 53 | @GET 54 | public Response findTasks(@QueryParam("description") String description, 55 | @QueryParam("completed") Boolean completed) { 56 | TaskFilter filter = new TaskFilter().setDescription(description).setCompleted(completed); 57 | List tasks = taskService.findTasks(filter); 58 | List queryTaskResults = taskMapper.toQueryTaskResults(tasks); 59 | return Response.ok(queryTaskResults).build(); 60 | } 61 | 62 | @DELETE 63 | public Response deleteTasks(@QueryParam("completed") Boolean completed) { 64 | taskService.deleteTasks(completed); 65 | return Response.noContent().build(); 66 | } 67 | 68 | @GET 69 | @Path("{taskId}") 70 | public Response getTask(@PathParam("taskId") Long taskId) { 71 | Task task = taskService.findTask(taskId).orElseThrow(NotFoundException::new); 72 | QueryTaskResult queryTaskResult = taskMapper.toQueryTaskResult(task); 73 | return Response.ok(queryTaskResult).build(); 74 | } 75 | 76 | @PUT 77 | @Path("{taskId}") 78 | public Response updateTask(@PathParam("taskId") Long taskId, 79 | @Valid @NotNull UpdateTaskDetails updateTaskDetails) { 80 | Task task = taskService.findTask(taskId).orElseThrow(NotFoundException::new); 81 | taskMapper.updateTask(updateTaskDetails, task); 82 | taskService.updateTask(task); 83 | return Response.noContent().build(); 84 | } 85 | 86 | @DELETE 87 | @Path("{taskId}") 88 | public Response deleteTask(@PathParam("taskId") Long taskId) { 89 | taskService.findTask(taskId).orElseThrow(NotFoundException::new); 90 | taskService.deleteTask(taskId); 91 | return Response.noContent().build(); 92 | } 93 | 94 | @PUT 95 | @Path("{taskId}/completed") 96 | public Response updateTaskStatus(@PathParam("taskId") Long taskId, 97 | @Valid @NotNull UpdateTaskStatusDetails updateTaskStatusDetails) { 98 | Task task = taskService.findTask(taskId).orElseThrow(NotFoundException::new); 99 | taskMapper.updateTask(updateTaskStatusDetails, task); 100 | taskService.updateTask(task); 101 | return Response.noContent().build(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/domain/Task.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.domain; 2 | 3 | import javax.persistence.*; 4 | import javax.validation.constraints.NotNull; 5 | import java.util.Date; 6 | 7 | /** 8 | * JPA entity that represents a task. 9 | * 10 | * @author cassiomolin 11 | */ 12 | @Entity 13 | public class Task { 14 | 15 | @Id 16 | @GeneratedValue 17 | private Long id; 18 | 19 | @NotNull 20 | private String description; 21 | 22 | @NotNull 23 | private Boolean completed; 24 | 25 | @Temporal(TemporalType.TIMESTAMP) 26 | private Date createdDate; 27 | 28 | public Task() { 29 | 30 | } 31 | 32 | public Long getId() { 33 | return id; 34 | } 35 | 36 | public void setId(Long id) { 37 | this.id = id; 38 | } 39 | 40 | public String getDescription() { 41 | return description; 42 | } 43 | 44 | public void setDescription(String description) { 45 | this.description = description; 46 | } 47 | 48 | public Boolean getCompleted() { 49 | return completed; 50 | } 51 | 52 | public void setCompleted(Boolean completed) { 53 | this.completed = completed; 54 | } 55 | 56 | public Date getCreatedDate() { 57 | return createdDate; 58 | } 59 | 60 | public void setCreatedDate(Date createdDate) { 61 | this.createdDate = createdDate; 62 | } 63 | 64 | @PrePersist 65 | void onCreate() { 66 | this.setCreatedDate(new Date()); 67 | } 68 | 69 | @Override 70 | public boolean equals(Object o) { 71 | if (this == o) return true; 72 | if (o == null || getClass() != o.getClass()) return false; 73 | 74 | Task task = (Task) o; 75 | 76 | if (id != null ? !id.equals(task.id) : task.id != null) return false; 77 | if (description != null ? !description.equals(task.description) : task.description != null) return false; 78 | return completed != null ? completed.equals(task.completed) : task.completed == null; 79 | } 80 | 81 | @Override 82 | public int hashCode() { 83 | int result = id != null ? id.hashCode() : 0; 84 | result = 31 * result + (description != null ? description.hashCode() : 0); 85 | result = 31 * result + (completed != null ? completed.hashCode() : 0); 86 | return result; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/domain/TaskFilter.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.domain; 2 | 3 | /** 4 | * Model that represents a filter for {@link Task}s. 5 | * 6 | * @author cassiomolin 7 | */ 8 | public class TaskFilter { 9 | 10 | private String description; 11 | private Boolean completed; 12 | 13 | public TaskFilter() { 14 | 15 | } 16 | 17 | public String getDescription() { 18 | return description; 19 | } 20 | 21 | public TaskFilter setDescription(String description) { 22 | this.description = description; 23 | return this; 24 | } 25 | 26 | public Boolean getCompleted() { 27 | return completed; 28 | } 29 | 30 | public TaskFilter setCompleted(Boolean completed) { 31 | this.completed = completed; 32 | return this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/repository/TaskRepository.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.repository; 2 | 3 | import com.cassiomolin.example.task.domain.Task; 4 | import org.springframework.data.repository.CrudRepository; 5 | import org.springframework.data.repository.query.QueryByExampleExecutor; 6 | import org.springframework.stereotype.Repository; 7 | 8 | /** 9 | * Repository for {@link Task}s. 10 | * 11 | * @author cassiomolin 12 | */ 13 | @Repository 14 | public interface TaskRepository extends CrudRepository, QueryByExampleExecutor { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/cassiomolin/example/task/service/TaskService.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.service; 2 | 3 | import com.cassiomolin.example.task.domain.Task; 4 | import com.cassiomolin.example.task.domain.TaskFilter; 5 | import com.cassiomolin.example.task.repository.TaskRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.data.domain.Example; 8 | import org.springframework.data.domain.ExampleMatcher; 9 | import org.springframework.data.domain.Sort; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.transaction.annotation.Transactional; 12 | 13 | import javax.validation.constraints.NotNull; 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | import java.util.Optional; 17 | 18 | /** 19 | * Service that provides operations for {@link Task}s. 20 | * 21 | * @author cassiomolin 22 | */ 23 | @Service 24 | @Transactional 25 | public class TaskService { 26 | 27 | @Autowired 28 | private TaskRepository taskRepository; 29 | 30 | /** 31 | * Create a task. 32 | * 33 | * @param task 34 | * @return 35 | */ 36 | public Task createTask(@NotNull Task task) { 37 | task.setCompleted(false); 38 | return taskRepository.save(task); 39 | } 40 | 41 | /** 42 | * Find all tasks. 43 | * 44 | * @return 45 | */ 46 | public List findAllTasks() { 47 | List list = new ArrayList<>(); 48 | taskRepository.findAll().forEach(list::add); 49 | return list; 50 | } 51 | 52 | /** 53 | * Find tasks using a filter. 54 | * 55 | * @param filter 56 | * @return 57 | */ 58 | public List findTasks(@NotNull TaskFilter filter) { 59 | 60 | Task task = new Task(); 61 | task.setDescription(filter.getDescription()); 62 | task.setCompleted(filter.getCompleted()); 63 | 64 | ExampleMatcher matcher = ExampleMatcher.matching() 65 | .withMatcher("description", match -> match.contains().ignoreCase()); 66 | Example example = Example.of(task, matcher); 67 | 68 | Sort sort = new Sort(Sort.Direction.ASC, "createdDate"); 69 | 70 | List list = new ArrayList<>(); 71 | taskRepository.findAll(example, sort).forEach(list::add); 72 | 73 | return list; 74 | } 75 | 76 | /** 77 | * Delete tasks. 78 | * 79 | * @param completed 80 | * @return 81 | */ 82 | public void deleteTasks(Boolean completed) { 83 | 84 | if (completed == null) { 85 | taskRepository.deleteAll(); 86 | } else { 87 | Task task = new Task(); 88 | task.setCompleted(completed); 89 | Example example = Example.of(task); 90 | taskRepository.delete(taskRepository.findAll(example)); 91 | } 92 | } 93 | 94 | /** 95 | * Find a task by id. 96 | * 97 | * @param taskId 98 | * @return 99 | */ 100 | public Optional findTask(@NotNull Long taskId) { 101 | return Optional.ofNullable(taskRepository.findOne(taskId)); 102 | } 103 | 104 | /** 105 | * Update a task. 106 | * 107 | * @param task 108 | */ 109 | public void updateTask(@NotNull Task task) { 110 | taskRepository.save(task); 111 | } 112 | 113 | /** 114 | * Delete a task. 115 | * 116 | * @param taskId 117 | */ 118 | public void deleteTask(@NotNull Long taskId) { 119 | taskRepository.delete(taskId); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/postman/tasks.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": [], 3 | "info": { 4 | "name": "tasks", 5 | "_postman_id": "e7d9ffe1-387b-6ac3-8373-083413533d29", 6 | "description": "", 7 | "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" 8 | }, 9 | "item": [ 10 | { 11 | "name": "Create a task", 12 | "request": { 13 | "url": "{{config.baseUrl}}/api/tasks", 14 | "method": "POST", 15 | "header": [ 16 | { 17 | "key": "Content-Type", 18 | "value": "application/json", 19 | "description": "" 20 | } 21 | ], 22 | "body": { 23 | "mode": "raw", 24 | "raw": "{\n \"description\": \"Pay internet bill\"\n}" 25 | }, 26 | "description": "" 27 | }, 28 | "response": [] 29 | }, 30 | { 31 | "name": "Get all tasks", 32 | "request": { 33 | "url": "{{config.baseUrl}}/api/tasks", 34 | "method": "GET", 35 | "header": [ 36 | { 37 | "key": "Accept", 38 | "value": "application/json", 39 | "description": "" 40 | } 41 | ], 42 | "body": { 43 | "mode": "raw", 44 | "raw": "" 45 | }, 46 | "description": "" 47 | }, 48 | "response": [] 49 | }, 50 | { 51 | "name": "Get tasks by description", 52 | "request": { 53 | "url": { 54 | "raw": "{{config.baseUrl}}/api/tasks?description=avocado", 55 | "auth": {}, 56 | "host": [ 57 | "{{config", 58 | "baseUrl}}" 59 | ], 60 | "path": [ 61 | "api", 62 | "tasks" 63 | ], 64 | "query": [ 65 | { 66 | "key": "description", 67 | "value": "avocado", 68 | "equals": true, 69 | "description": "" 70 | } 71 | ], 72 | "variable": [] 73 | }, 74 | "method": "GET", 75 | "header": [ 76 | { 77 | "key": "Accept", 78 | "value": "application/json", 79 | "description": "" 80 | } 81 | ], 82 | "body": { 83 | "mode": "raw", 84 | "raw": "" 85 | }, 86 | "description": "" 87 | }, 88 | "response": [] 89 | }, 90 | { 91 | "name": "Get tasks by completed status", 92 | "request": { 93 | "url": { 94 | "raw": "{{config.baseUrl}}/api/tasks?completed=true", 95 | "auth": {}, 96 | "host": [ 97 | "{{config", 98 | "baseUrl}}" 99 | ], 100 | "path": [ 101 | "api", 102 | "tasks" 103 | ], 104 | "query": [ 105 | { 106 | "key": "completed", 107 | "value": "true", 108 | "equals": true, 109 | "description": "" 110 | } 111 | ], 112 | "variable": [] 113 | }, 114 | "method": "GET", 115 | "header": [ 116 | { 117 | "key": "Accept", 118 | "value": "application/json", 119 | "description": "" 120 | } 121 | ], 122 | "body": { 123 | "mode": "raw", 124 | "raw": "" 125 | }, 126 | "description": "" 127 | }, 128 | "response": [] 129 | }, 130 | { 131 | "name": "Get all tasks by description and completed status", 132 | "request": { 133 | "url": { 134 | "raw": "{{config.baseUrl}}/api/tasks?description=karate&completed=true", 135 | "auth": {}, 136 | "host": [ 137 | "{{config", 138 | "baseUrl}}" 139 | ], 140 | "path": [ 141 | "api", 142 | "tasks" 143 | ], 144 | "query": [ 145 | { 146 | "key": "description", 147 | "value": "karate", 148 | "equals": true, 149 | "description": "" 150 | }, 151 | { 152 | "key": "completed", 153 | "value": "true", 154 | "equals": true, 155 | "description": "" 156 | } 157 | ], 158 | "variable": [] 159 | }, 160 | "method": "GET", 161 | "header": [ 162 | { 163 | "key": "Accept", 164 | "value": "application/json", 165 | "description": "" 166 | } 167 | ], 168 | "body": { 169 | "mode": "raw", 170 | "raw": "" 171 | }, 172 | "description": "" 173 | }, 174 | "response": [] 175 | }, 176 | { 177 | "name": "Get a task by id", 178 | "request": { 179 | "url": "{{config.baseUrl}}/api/tasks/5", 180 | "method": "GET", 181 | "header": [ 182 | { 183 | "key": "Accept", 184 | "value": "application/json", 185 | "description": "" 186 | } 187 | ], 188 | "body": { 189 | "mode": "raw", 190 | "raw": "" 191 | }, 192 | "description": "" 193 | }, 194 | "response": [] 195 | }, 196 | { 197 | "name": "Update a task", 198 | "request": { 199 | "url": "{{config.baseUrl}}/api/tasks/5", 200 | "method": "PUT", 201 | "header": [ 202 | { 203 | "key": "Content-Type", 204 | "value": "application/json", 205 | "description": "" 206 | } 207 | ], 208 | "body": { 209 | "mode": "raw", 210 | "raw": "{\n \"description\": \"Pay electricity bill\",\n \"completed\": false\n}" 211 | }, 212 | "description": "" 213 | }, 214 | "response": [] 215 | }, 216 | { 217 | "name": "Update a task completed status", 218 | "request": { 219 | "url": "{{config.baseUrl}}/api/tasks/5/completed", 220 | "method": "PUT", 221 | "header": [ 222 | { 223 | "key": "Content-Type", 224 | "value": "application/json", 225 | "description": "" 226 | } 227 | ], 228 | "body": { 229 | "mode": "raw", 230 | "raw": "{\n \"value\": true\n}" 231 | }, 232 | "description": "" 233 | }, 234 | "response": [] 235 | }, 236 | { 237 | "name": "Delete a task", 238 | "request": { 239 | "url": "{{config.baseUrl}}/api/tasks/5", 240 | "method": "DELETE", 241 | "header": [], 242 | "body": { 243 | "mode": "raw", 244 | "raw": "" 245 | }, 246 | "description": "" 247 | }, 248 | "response": [] 249 | }, 250 | { 251 | "name": "Delete all tasks", 252 | "request": { 253 | "url": "{{config.baseUrl}}/api/tasks", 254 | "method": "DELETE", 255 | "header": [], 256 | "body": { 257 | "mode": "raw", 258 | "raw": "" 259 | }, 260 | "description": "" 261 | }, 262 | "response": [] 263 | }, 264 | { 265 | "name": "Delete all tasks by completed status", 266 | "request": { 267 | "url": { 268 | "raw": "{{config.baseUrl}}/api/tasks?completed=true", 269 | "auth": {}, 270 | "host": [ 271 | "{{config", 272 | "baseUrl}}" 273 | ], 274 | "path": [ 275 | "api", 276 | "tasks" 277 | ], 278 | "query": [ 279 | { 280 | "key": "completed", 281 | "value": "true", 282 | "equals": true, 283 | "description": "" 284 | } 285 | ], 286 | "variable": [] 287 | }, 288 | "method": "DELETE", 289 | "header": [], 290 | "body": { 291 | "mode": "raw", 292 | "raw": "" 293 | }, 294 | "description": "" 295 | }, 296 | "response": [] 297 | } 298 | ] 299 | } -------------------------------------------------------------------------------- /src/main/postman/tasks.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "73c166a1-12b1-a822-bbdb-39a38a2dea93", 3 | "name": "tasks", 4 | "values": [ 5 | { 6 | "enabled": true, 7 | "key": "config.baseUrl", 8 | "value": "http://localhost:8080", 9 | "type": "text" 10 | } 11 | ], 12 | "timestamp": 1499450877479, 13 | "_postman_variable_scope": "environment", 14 | "_postman_exported_at": "2017-07-07T18:09:38.434Z", 15 | "_postman_exported_using": "Postman/4.11.1" 16 | } -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cassiomolin/tasks-rest-api/c470ddcd2a3a43629fff7f6d26b8106084b9614c/src/main/resources/application.yaml -------------------------------------------------------------------------------- /src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO task (id, description, completed, created_date) VALUES (1, 'Buy avocado', FALSE, CURRENT_TIMESTAMP); 2 | INSERT INTO task (id, description, completed, created_date) VALUES (2, 'Water the plants', TRUE, CURRENT_TIMESTAMP); 3 | INSERT INTO task (id, description, completed, created_date) VALUES (3, 'Feed the hamster', FALSE, CURRENT_TIMESTAMP); 4 | INSERT INTO task (id, description, completed, created_date) VALUES (4, 'Take grandma to karate lessons', TRUE, CURRENT_TIMESTAMP); -------------------------------------------------------------------------------- /src/test/java/com/cassiomolin/example/task/api/TaskResourceTest.java: -------------------------------------------------------------------------------- 1 | package com.cassiomolin.example.task.api; 2 | 3 | import com.cassiomolin.example.task.api.model.CreateTaskDetails; 4 | import com.cassiomolin.example.task.api.model.QueryTaskResult; 5 | import com.cassiomolin.example.task.api.model.UpdateTaskDetails; 6 | import com.cassiomolin.example.task.api.model.UpdateTaskStatusDetails; 7 | import io.restassured.RestAssured; 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.springframework.boot.context.embedded.LocalServerPort; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.test.context.jdbc.Sql; 14 | import org.springframework.test.context.junit4.SpringRunner; 15 | 16 | import javax.ws.rs.core.HttpHeaders; 17 | import javax.ws.rs.core.MediaType; 18 | import javax.ws.rs.core.Response.Status; 19 | import java.util.Arrays; 20 | 21 | import static io.restassured.RestAssured.given; 22 | import static org.hamcrest.Matchers.notNullValue; 23 | import static org.hibernate.validator.internal.util.Contracts.assertNotNull; 24 | 25 | /** 26 | * Tests for the task resource class. 27 | * 28 | * @author cassiomolin 29 | */ 30 | @RunWith(SpringRunner.class) 31 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 32 | public class TaskResourceTest { 33 | 34 | @LocalServerPort 35 | private int port; 36 | 37 | @Before 38 | public void setUp() throws Exception { 39 | RestAssured.baseURI = "http://localhost"; 40 | RestAssured.port = port; 41 | RestAssured.basePath = "/api"; 42 | } 43 | 44 | @Test 45 | public void createTask() { 46 | 47 | CreateTaskDetails createTaskDetails = new CreateTaskDetails(); 48 | createTaskDetails.setDescription("Pay electricity bill"); 49 | 50 | given() 51 | .accept(MediaType.APPLICATION_JSON) 52 | .contentType(MediaType.APPLICATION_JSON) 53 | .body(createTaskDetails) 54 | .expect() 55 | .statusCode(Status.CREATED.getStatusCode()) 56 | .header(HttpHeaders.LOCATION, notNullValue()) 57 | .when() 58 | .post("/tasks") 59 | .then() 60 | .log().all(); 61 | } 62 | 63 | @Test 64 | public void createTaskWithInvalidDetails() { 65 | 66 | CreateTaskDetails createTaskDetails = new CreateTaskDetails(); 67 | 68 | given() 69 | .accept(MediaType.APPLICATION_JSON) 70 | .contentType(MediaType.APPLICATION_JSON) 71 | .body(createTaskDetails) 72 | .expect() 73 | .statusCode(Status.BAD_REQUEST.getStatusCode()) 74 | .when() 75 | .post("/tasks") 76 | .then() 77 | .log().all(); 78 | } 79 | 80 | @Test 81 | public void findTasks() { 82 | 83 | QueryTaskResult[] taskQueryResults = 84 | 85 | given() 86 | .accept(MediaType.APPLICATION_JSON) 87 | .expect() 88 | .statusCode(Status.OK.getStatusCode()) 89 | .contentType(MediaType.APPLICATION_JSON) 90 | .when() 91 | .get("/tasks") 92 | .then() 93 | .log().all() 94 | .extract() 95 | .body().as(QueryTaskResult[].class); 96 | 97 | Arrays.stream(taskQueryResults).forEach(task -> { 98 | assertNotNull(task.getId()); 99 | assertNotNull(task.getDescription()); 100 | assertNotNull(task.getCompleted()); 101 | }); 102 | } 103 | 104 | @Test 105 | public void getTask() { 106 | 107 | QueryTaskResult queryTaskResult = 108 | 109 | given() 110 | .accept(MediaType.APPLICATION_JSON) 111 | .pathParam("taskId", 1) 112 | .expect() 113 | .statusCode(Status.OK.getStatusCode()) 114 | .contentType(MediaType.APPLICATION_JSON) 115 | .when() 116 | .get("/tasks/{taskId}") 117 | .then() 118 | .log().all() 119 | .extract() 120 | .body().as(QueryTaskResult.class); 121 | 122 | assertNotNull(queryTaskResult.getId()); 123 | assertNotNull(queryTaskResult.getDescription()); 124 | assertNotNull(queryTaskResult.getCompleted()); 125 | } 126 | 127 | @Test 128 | public void getTaskWithInvalidIdentifier() { 129 | 130 | given() 131 | .accept(MediaType.APPLICATION_JSON) 132 | .pathParam("taskId", Integer.MAX_VALUE) 133 | .expect() 134 | .statusCode(Status.NOT_FOUND.getStatusCode()) 135 | .contentType(MediaType.APPLICATION_JSON) 136 | .when() 137 | .get("/tasks/{taskId}") 138 | .then() 139 | .log().all(); 140 | } 141 | 142 | @Test 143 | public void updateTask() { 144 | 145 | UpdateTaskDetails updateTaskDetails = new UpdateTaskDetails(); 146 | updateTaskDetails.setDescription("Buy chocolate"); 147 | updateTaskDetails.setCompleted(false); 148 | 149 | given() 150 | .accept(MediaType.APPLICATION_JSON) 151 | .contentType(MediaType.APPLICATION_JSON) 152 | .body(updateTaskDetails) 153 | .pathParam("taskId", 1) 154 | .expect() 155 | .statusCode(Status.NO_CONTENT.getStatusCode()) 156 | .when() 157 | .put("/tasks/{taskId}") 158 | .then() 159 | .log().all(); 160 | } 161 | 162 | @Test 163 | public void updateTaskWithInvalidDetails() { 164 | 165 | UpdateTaskDetails updateTaskDetails = new UpdateTaskDetails(); 166 | 167 | given() 168 | .accept(MediaType.APPLICATION_JSON) 169 | .contentType(MediaType.APPLICATION_JSON) 170 | .body(updateTaskDetails) 171 | .pathParam("taskId", 1) 172 | .expect() 173 | .statusCode(Status.BAD_REQUEST.getStatusCode()) 174 | .when() 175 | .put("/tasks/{taskId}") 176 | .then() 177 | .log().all(); 178 | } 179 | 180 | @Test 181 | public void deleteTask() { 182 | 183 | given() 184 | .pathParam("taskId", 1) 185 | .expect() 186 | .statusCode(Status.NO_CONTENT.getStatusCode()) 187 | .when() 188 | .delete("/tasks/{taskId}") 189 | .then() 190 | .log().all(); 191 | } 192 | 193 | @Test 194 | public void deleteAllCompletedTasks() { 195 | 196 | given() 197 | .queryParam("completed", true) 198 | .expect() 199 | .statusCode(Status.NO_CONTENT.getStatusCode()) 200 | .when() 201 | .delete("/tasks") 202 | .then() 203 | .log().all(); 204 | } 205 | 206 | 207 | @Test 208 | public void deleteTaskWithInvalidIdentifier() { 209 | 210 | given() 211 | .pathParam("taskId", Integer.MAX_VALUE) 212 | .expect() 213 | .statusCode(Status.NOT_FOUND.getStatusCode()) 214 | .when() 215 | .delete("/tasks/{taskId}") 216 | .then() 217 | .log().all(); 218 | } 219 | 220 | @Test 221 | public void updateTaskStatus() { 222 | 223 | UpdateTaskStatusDetails updateTaskStatusDetails = new UpdateTaskStatusDetails(); 224 | updateTaskStatusDetails.setValue(true); 225 | 226 | given() 227 | .accept(MediaType.APPLICATION_JSON) 228 | .contentType(MediaType.APPLICATION_JSON) 229 | .body(updateTaskStatusDetails) 230 | .pathParam("taskId", 1) 231 | .expect() 232 | .statusCode(Status.NO_CONTENT.getStatusCode()) 233 | .when() 234 | .put("/tasks/{taskId}/completed") 235 | .then() 236 | .log().all(); 237 | } 238 | } 239 | --------------------------------------------------------------------------------