mirror of
https://github.com/Dannecron/spring-boot-demo.git
synced 2025-12-25 16:22:35 +03:00
add request validation
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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<Any> {
|
||||
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<Any> {
|
||||
val saved = productService.create(
|
||||
product.name,
|
||||
product.price,
|
||||
product.description,
|
||||
)
|
||||
|
||||
return Json.encodeToJsonElement(value = saved).toString()
|
||||
return ResponseEntity(saved, HttpStatus.CREATED)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{guid}")
|
||||
|
||||
@@ -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<Any> = ResponseEntity(
|
||||
makeBadRequestResponse(exception.message.toString()),
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
// 404
|
||||
@ExceptionHandler(NotFoundException::class)
|
||||
fun handleNotFound(): ResponseEntity<Any> = ResponseEntity(makeNotFoundResponse(), HttpStatus.NOT_FOUND)
|
||||
|
||||
// 422
|
||||
@ExceptionHandler(UnprocessableException::class)
|
||||
fun handleUnprocessable(exception: UnprocessableException): ResponseEntity<Any> = ResponseEntity(
|
||||
makeUnprocessableResponse(exception.message),
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity<Any> = ResponseEntity(
|
||||
makeUnprocessableResponseWithErrors(exception.javaClass.name, exception.allErrors),
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.example.demo.responses
|
||||
|
||||
import org.springframework.validation.ObjectError
|
||||
|
||||
class UnprocessableResponse(
|
||||
val cause: String,
|
||||
val errors: List<ObjectError>? = null
|
||||
): BaseResponse(status = ResponseStatus.UNPROCESSABLE)
|
||||
|
||||
fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause)
|
||||
fun makeUnprocessableResponseWithErrors(
|
||||
cause: String, errors: List<ObjectError>,
|
||||
): UnprocessableResponse = UnprocessableResponse(cause, errors)
|
||||
@@ -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) } }
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user