diff --git a/build.gradle.kts b/build.gradle.kts index dea97c8..6ba44cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json") implementation("org.postgresql:postgresql") implementation("org.springframework.boot:spring-boot-starter-mustache") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") developmentOnly("org.springframework.boot:spring-boot-devtools") diff --git a/src/main/kotlin/com/example/demo/controllers/ProductController.kt b/src/main/kotlin/com/example/demo/controllers/ProductController.kt index b01c66c..d19e68d 100644 --- a/src/main/kotlin/com/example/demo/controllers/ProductController.kt +++ b/src/main/kotlin/com/example/demo/controllers/ProductController.kt @@ -5,8 +5,7 @@ import com.example.demo.exceptions.UnprocessableException import com.example.demo.requests.CreateProductRequest import com.example.demo.responses.makeOkResponse import com.example.demo.services.ProductService -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.encodeToJsonElement +import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -16,31 +15,31 @@ import java.util.* @RestController @RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE]) class ProductController( - val productService: ProductService + val productService: ProductService, ) { @GetMapping("/{guid}") @ResponseBody @Throws(NotFoundException::class) fun getProduct( - @PathVariable guid: UUID - ): String { + @PathVariable guid: UUID, + ): ResponseEntity { val product = productService.findByGuid(guid = guid) ?: throw NotFoundException() - return Json.encodeToJsonElement(value = product).toString() + return ResponseEntity(product, HttpStatus.OK) } - @PostMapping(value = ["/"], consumes = [MediaType.APPLICATION_JSON_VALUE]) + @PostMapping(value = [""], consumes = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody fun createProduct( - @RequestBody product: CreateProductRequest - ): String { + @Valid @RequestBody product: CreateProductRequest, + ): ResponseEntity { val saved = productService.create( product.name, product.price, product.description, ) - return Json.encodeToJsonElement(value = saved).toString() + return ResponseEntity(saved, HttpStatus.CREATED) } @DeleteMapping("/{guid}") diff --git a/src/main/kotlin/com/example/demo/exceptions/ExceptionHandler.kt b/src/main/kotlin/com/example/demo/exceptions/ExceptionHandler.kt index be26535..af146e5 100644 --- a/src/main/kotlin/com/example/demo/exceptions/ExceptionHandler.kt +++ b/src/main/kotlin/com/example/demo/exceptions/ExceptionHandler.kt @@ -1,20 +1,41 @@ package com.example.demo.exceptions +import com.example.demo.responses.makeBadRequestResponse import com.example.demo.responses.makeNotFoundResponse import com.example.demo.responses.makeUnprocessableResponse +import com.example.demo.responses.makeUnprocessableResponseWithErrors import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler @ControllerAdvice class ExceptionHandler { + /* 4xx status codes */ + + // 400 + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleMessageNotReadable(exception: HttpMessageNotReadableException): ResponseEntity = ResponseEntity( + makeBadRequestResponse(exception.message.toString()), + HttpStatus.BAD_REQUEST, + ) + + // 404 @ExceptionHandler(NotFoundException::class) fun handleNotFound(): ResponseEntity = ResponseEntity(makeNotFoundResponse(), HttpStatus.NOT_FOUND) + // 422 @ExceptionHandler(UnprocessableException::class) fun handleUnprocessable(exception: UnprocessableException): ResponseEntity = ResponseEntity( makeUnprocessableResponse(exception.message), HttpStatus.UNPROCESSABLE_ENTITY, ) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity = ResponseEntity( + makeUnprocessableResponseWithErrors(exception.javaClass.name, exception.allErrors), + HttpStatus.UNPROCESSABLE_ENTITY, + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/requests/CreateProductRequest.kt b/src/main/kotlin/com/example/demo/requests/CreateProductRequest.kt index 9385934..447ec3f 100644 --- a/src/main/kotlin/com/example/demo/requests/CreateProductRequest.kt +++ b/src/main/kotlin/com/example/demo/requests/CreateProductRequest.kt @@ -1,7 +1,12 @@ package com.example.demo.requests +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank + data class CreateProductRequest( + @field:NotBlank(message = "name value required") val name: String, val description: String?, + @field:Min(value = 0, message = "price must be positive value") val price: Long, ) diff --git a/src/main/kotlin/com/example/demo/responses/BadRequestResponse.kt b/src/main/kotlin/com/example/demo/responses/BadRequestResponse.kt new file mode 100644 index 0000000..a227ac4 --- /dev/null +++ b/src/main/kotlin/com/example/demo/responses/BadRequestResponse.kt @@ -0,0 +1,7 @@ +package com.example.demo.responses + +data class BadRequestResponse( + val cause: String, +): BaseResponse(status = ResponseStatus.BAD_REQUEST) + +fun makeBadRequestResponse(cause: String): BadRequestResponse = BadRequestResponse(cause) diff --git a/src/main/kotlin/com/example/demo/responses/ResponseStatus.kt b/src/main/kotlin/com/example/demo/responses/ResponseStatus.kt index e7f76c8..cca92fd 100644 --- a/src/main/kotlin/com/example/demo/responses/ResponseStatus.kt +++ b/src/main/kotlin/com/example/demo/responses/ResponseStatus.kt @@ -5,5 +5,6 @@ import com.fasterxml.jackson.annotation.JsonValue enum class ResponseStatus(@JsonValue val status: String) { OK("ok"), NOT_FOUND("not found"), + BAD_REQUEST("bad request"), UNPROCESSABLE("unprocessable"); } \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/responses/UnprocessableResponse.kt b/src/main/kotlin/com/example/demo/responses/UnprocessableResponse.kt index 39075e3..2b66600 100644 --- a/src/main/kotlin/com/example/demo/responses/UnprocessableResponse.kt +++ b/src/main/kotlin/com/example/demo/responses/UnprocessableResponse.kt @@ -1,7 +1,13 @@ package com.example.demo.responses +import org.springframework.validation.ObjectError + class UnprocessableResponse( val cause: String, + val errors: List? = null ): BaseResponse(status = ResponseStatus.UNPROCESSABLE) -fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause) \ No newline at end of file +fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause) +fun makeUnprocessableResponseWithErrors( + cause: String, errors: List, +): UnprocessableResponse = UnprocessableResponse(cause, errors) \ No newline at end of file diff --git a/src/test/kotlin/com/example/demo/controllers/ProductControllerTest.kt b/src/test/kotlin/com/example/demo/controllers/ProductControllerTest.kt index 4c53d69..3e6df6b 100644 --- a/src/test/kotlin/com/example/demo/controllers/ProductControllerTest.kt +++ b/src/test/kotlin/com/example/demo/controllers/ProductControllerTest.kt @@ -3,16 +3,22 @@ package com.example.demo.controllers import com.example.demo.models.Product import com.example.demo.responses.ResponseStatus import com.example.demo.services.ProductService +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.web.bind.MethodArgumentNotValidException import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.util.* @@ -21,6 +27,8 @@ import java.util.* class ProductControllerTest(@Autowired val mockMvc: MockMvc) { @MockBean private lateinit var productService: ProductService + private val mapper = jacksonObjectMapper() + @Test fun getProduct_success() { @@ -43,7 +51,7 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc) { mockMvc.get("/api/product/$guid") .andExpect { status { status { isOk() } } } - .andExpect { content { contentType("application/json") } } + .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.id") { value(product.id.toString()) } } .andExpect { jsonPath("\$.guid") { value(guid.toString()) } } .andExpect { jsonPath("\$.name") { value("some") } } @@ -61,7 +69,85 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc) { mockMvc.get("/api/product/$guid") .andExpect { status { status { isNotFound() } } } - .andExpect { content { contentType("application/json") } } + .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.status") { value(ResponseStatus.NOT_FOUND.status) } } } + + @Test + fun createProduct_success() { + val productId = 13.toLong() + val name = "new-product" + val description = null + val price = 20000.toLong() + + val reqBody = mapper.writeValueAsString( + mapOf("name" to name, "description" to description, "price" to price) + ) + + whenever(productService.create( + eq(name), + eq(price), + eq(description) + )) doReturn Product( + id = productId, + guid = UUID.randomUUID(), + name = name, + description = description, + price = price, + createdAt = OffsetDateTime.now(), + updatedAt = null, + deletedAt = null, + ) + + mockMvc.post("/api/product") { + contentType = MediaType.APPLICATION_JSON + content = reqBody + } + .andExpect { status { status { isCreated() } } } + .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } + .andExpect { jsonPath("\$.id") { value(productId) } } + } + + @Test + fun createProduct_badRequest_noNameParam() { + val description = null + val price = 20000.toLong() + + val reqBody = mapper.writeValueAsString( + mapOf("description" to description, "price" to price) + ) + + verifyNoInteractions(productService) + + mockMvc.post("/api/product") { + contentType = MediaType.APPLICATION_JSON + content = reqBody + } + .andExpect { status { status { isBadRequest() } } } + .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } + .andExpect { jsonPath("\$.status") { value(ResponseStatus.BAD_REQUEST.status) } } + .andExpect { jsonPath("\$.cause") { contains("name") } } + } + + @Test + fun createProduct_badRequest_emptyName() { + val description = null + val price = 20000.toLong() + + val reqBody = mapper.writeValueAsString( + mapOf("name" to "", "description" to description, "price" to price) + ) + + verifyNoInteractions(productService) + + mockMvc.post("/api/product") { + contentType = MediaType.APPLICATION_JSON + content = reqBody + } + .andExpect { status { status { isUnprocessableEntity() } } } + .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } + .andExpect { jsonPath("\$.status") { value(ResponseStatus.UNPROCESSABLE.status) } } + .andExpect { jsonPath("\$.cause") { value(MethodArgumentNotValidException::class.qualifiedName) } } + + } } \ No newline at end of file