add request validation

This commit is contained in:
Denis Savosin
2024-09-30 15:27:45 +07:00
parent 27595e08dc
commit f9632ac568
8 changed files with 139 additions and 13 deletions

View File

@@ -30,6 +30,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
implementation("org.postgresql:postgresql") implementation("org.postgresql:postgresql")
implementation("org.springframework.boot:spring-boot-starter-mustache") implementation("org.springframework.boot:spring-boot-starter-mustache")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")

View File

@@ -5,8 +5,7 @@ import com.example.demo.exceptions.UnprocessableException
import com.example.demo.requests.CreateProductRequest import com.example.demo.requests.CreateProductRequest
import com.example.demo.responses.makeOkResponse import com.example.demo.responses.makeOkResponse
import com.example.demo.services.ProductService import com.example.demo.services.ProductService
import kotlinx.serialization.json.Json import jakarta.validation.Valid
import kotlinx.serialization.json.encodeToJsonElement
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -16,31 +15,31 @@ import java.util.*
@RestController @RestController
@RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE]) @RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE])
class ProductController( class ProductController(
val productService: ProductService val productService: ProductService,
) { ) {
@GetMapping("/{guid}") @GetMapping("/{guid}")
@ResponseBody @ResponseBody
@Throws(NotFoundException::class) @Throws(NotFoundException::class)
fun getProduct( fun getProduct(
@PathVariable guid: UUID @PathVariable guid: UUID,
): String { ): ResponseEntity<Any> {
val product = productService.findByGuid(guid = guid) ?: throw NotFoundException() 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 @ResponseBody
fun createProduct( fun createProduct(
@RequestBody product: CreateProductRequest @Valid @RequestBody product: CreateProductRequest,
): String { ): ResponseEntity<Any> {
val saved = productService.create( val saved = productService.create(
product.name, product.name,
product.price, product.price,
product.description, product.description,
) )
return Json.encodeToJsonElement(value = saved).toString() return ResponseEntity(saved, HttpStatus.CREATED)
} }
@DeleteMapping("/{guid}") @DeleteMapping("/{guid}")

View File

@@ -1,20 +1,41 @@
package com.example.demo.exceptions package com.example.demo.exceptions
import com.example.demo.responses.makeBadRequestResponse
import com.example.demo.responses.makeNotFoundResponse import com.example.demo.responses.makeNotFoundResponse
import com.example.demo.responses.makeUnprocessableResponse import com.example.demo.responses.makeUnprocessableResponse
import com.example.demo.responses.makeUnprocessableResponseWithErrors
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity 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.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
@ControllerAdvice @ControllerAdvice
class ExceptionHandler { class ExceptionHandler {
/* 4xx status codes */
// 400
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleMessageNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<Any> = ResponseEntity(
makeBadRequestResponse(exception.message.toString()),
HttpStatus.BAD_REQUEST,
)
// 404
@ExceptionHandler(NotFoundException::class) @ExceptionHandler(NotFoundException::class)
fun handleNotFound(): ResponseEntity<Any> = ResponseEntity(makeNotFoundResponse(), HttpStatus.NOT_FOUND) fun handleNotFound(): ResponseEntity<Any> = ResponseEntity(makeNotFoundResponse(), HttpStatus.NOT_FOUND)
// 422
@ExceptionHandler(UnprocessableException::class) @ExceptionHandler(UnprocessableException::class)
fun handleUnprocessable(exception: UnprocessableException): ResponseEntity<Any> = ResponseEntity( fun handleUnprocessable(exception: UnprocessableException): ResponseEntity<Any> = ResponseEntity(
makeUnprocessableResponse(exception.message), makeUnprocessableResponse(exception.message),
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
) )
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity<Any> = ResponseEntity(
makeUnprocessableResponseWithErrors(exception.javaClass.name, exception.allErrors),
HttpStatus.UNPROCESSABLE_ENTITY,
)
} }

View File

@@ -1,7 +1,12 @@
package com.example.demo.requests package com.example.demo.requests
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
data class CreateProductRequest( data class CreateProductRequest(
@field:NotBlank(message = "name value required")
val name: String, val name: String,
val description: String?, val description: String?,
@field:Min(value = 0, message = "price must be positive value")
val price: Long, val price: Long,
) )

View File

@@ -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)

View File

@@ -5,5 +5,6 @@ import com.fasterxml.jackson.annotation.JsonValue
enum class ResponseStatus(@JsonValue val status: String) { enum class ResponseStatus(@JsonValue val status: String) {
OK("ok"), OK("ok"),
NOT_FOUND("not found"), NOT_FOUND("not found"),
BAD_REQUEST("bad request"),
UNPROCESSABLE("unprocessable"); UNPROCESSABLE("unprocessable");
} }

View File

@@ -1,7 +1,13 @@
package com.example.demo.responses package com.example.demo.responses
import org.springframework.validation.ObjectError
class UnprocessableResponse( class UnprocessableResponse(
val cause: String, val cause: String,
val errors: List<ObjectError>? = null
): BaseResponse(status = ResponseStatus.UNPROCESSABLE) ): BaseResponse(status = ResponseStatus.UNPROCESSABLE)
fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause) fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause)
fun makeUnprocessableResponseWithErrors(
cause: String, errors: List<ObjectError>,
): UnprocessableResponse = UnprocessableResponse(cause, errors)

View File

@@ -3,16 +3,22 @@ package com.example.demo.controllers
import com.example.demo.models.Product import com.example.demo.models.Product
import com.example.demo.responses.ResponseStatus import com.example.demo.responses.ResponseStatus
import com.example.demo.services.ProductService 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.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean 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.MockMvc
import org.springframework.test.web.servlet.get 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.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
@@ -21,6 +27,8 @@ import java.util.*
class ProductControllerTest(@Autowired val mockMvc: MockMvc) { class ProductControllerTest(@Autowired val mockMvc: MockMvc) {
@MockBean @MockBean
private lateinit var productService: ProductService private lateinit var productService: ProductService
private val mapper = jacksonObjectMapper()
@Test @Test
fun getProduct_success() { fun getProduct_success() {
@@ -43,7 +51,7 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc) {
mockMvc.get("/api/product/$guid") mockMvc.get("/api/product/$guid")
.andExpect { status { status { isOk() } } } .andExpect { status { status { isOk() } } }
.andExpect { content { contentType("application/json") } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.id") { value(product.id.toString()) } } .andExpect { jsonPath("\$.id") { value(product.id.toString()) } }
.andExpect { jsonPath("\$.guid") { value(guid.toString()) } } .andExpect { jsonPath("\$.guid") { value(guid.toString()) } }
.andExpect { jsonPath("\$.name") { value("some") } } .andExpect { jsonPath("\$.name") { value("some") } }
@@ -61,7 +69,85 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc) {
mockMvc.get("/api/product/$guid") mockMvc.get("/api/product/$guid")
.andExpect { status { status { isNotFound() } } } .andExpect { status { status { isNotFound() } } }
.andExpect { content { contentType("application/json") } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.NOT_FOUND.status) } } .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) } }
}
} }