Skip to content

Commit

Permalink
Merge pull request #27 from impati/feature/product-19
Browse files Browse the repository at this point in the history
상품 CRUD 작업분 리팩토링
  • Loading branch information
impati authored Mar 30, 2024
2 parents 192a047 + 2666d92 commit 4e689da
Show file tree
Hide file tree
Showing 16 changed files with 142 additions and 81 deletions.
4 changes: 3 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ subprojects {
plugin("kotlin-allopen")
plugin("jacoco")
}

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.0")
Expand All @@ -54,6 +54,8 @@ subprojects {
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

compileOnly("io.github.oshai:kotlin-logging-jvm:4.0.0")

implementation("org.springframework.boot:spring-boot-starter")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,9 @@ class ProductController(
@PathVariable productId: Long,
@Valid @RequestBody request: ProductEditRequest
): ResponseEntity<ProductResponse> {
return ResponseEntity.ok(
productApplication.editProduct(
productId,
request,
UpdatedAudit(now(), request.memberNumber)
)
)
val product = productApplication.editProduct(productId, request, UpdatedAudit(now(), request.memberNumber))

return ResponseEntity.ok(product)
}

@DeleteMapping("/v1/products/{productId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ProductHistoryController(
fun getProductHistories(@PathVariable productId: Long): List<ProductHistoryResponse> {

return productHistoryQueryService.getProductHistories(productId)
.map { ProductHistoryResponse.from(it) }
.map(ProductHistoryResponse::from)
.toList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.productapi.product.api.exceptionhandler

class Error(
val fieldName: String,
val rejectedValue: String
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.example.productapi.product.api.exceptionhandler

class ErrorResponse(
class ErrorResponse<T>(
val statusCode: String,
val message: String
val message: String,
val errorData: T? = null
) {
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,70 @@
package com.example.productapi.product.api.exceptionhandler

import com.example.productdomain.product.exception.ProductOptimisticException
import lombok.extern.slf4j.Slf4j
import io.github.oshai.kotlinlogging.KotlinLogging
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.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

private const val CLIENT_ERROR_MESSAGE = "입력값이 잘못되었습니다."
private const val UNKNOWN_ERROR_MESSAGE = "알 수 없는 예외입니다. 로그를 확인해주세요."

private val log = KotlinLogging.logger {}

@Slf4j
@RestControllerAdvice
class ProductExceptionHandler {

@ExceptionHandler(Exception::class)
fun handleException(exception: Exception): ResponseEntity<ErrorResponse<Unit>> {
log.error("UNKNOWN exception : ${exception.message}")

return ResponseEntity.badRequest()
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, UNKNOWN_ERROR_MESSAGE))
}

/**
* - 요청 필드가 nullable 하지 않는데 null 요청인 경우
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadableExceptions(exception: HttpMessageNotReadableException): ResponseEntity<ErrorResponse<Unit>> {
log.warn("HttpMessageNotReadableException : ${exception.message}")

return ResponseEntity.badRequest()
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, exception.message ?: CLIENT_ERROR_MESSAGE))
}

/**
* - jakarta.validation.constraints 벨리데이션에 실패한 경우
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValidExceptions(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
val errorMessage = "입력값이 잘못되었습니다.\n"
fun handleMethodArgumentNotValidExceptions(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponse<List<Error>>> {
log.warn("MethodArgumentNotValidException : ${exception.message}")

val errorData = exception.fieldErrors
.map { Error(it.field, it.rejectedValue.toString()) }
.toList()

return ResponseEntity.badRequest()
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, errorMessage))
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, "Validation failed for argument", errorData))
}

@ExceptionHandler(IllegalArgumentException::class)
fun handleArgumentExceptions(exception: IllegalArgumentException): ResponseEntity<ErrorResponse> {
val errorMessage = "입력값이 잘못되었습니다.\n"
fun handleArgumentExceptions(exception: IllegalArgumentException): ResponseEntity<ErrorResponse<Unit>> {
log.warn("IllegalArgumentException : ${exception.message}")

return ResponseEntity.badRequest()
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, exception.message ?: errorMessage))
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, exception.message ?: CLIENT_ERROR_MESSAGE))
}

/**
* - 상품 수정 시 낙관적 실패인 경우
*/
@ExceptionHandler(ProductOptimisticException::class)
fun handleOptimisticLockingFailureExceptions(exception: ProductOptimisticException): ResponseEntity<ErrorResponse> {
fun handleOptimisticLockingFailureExceptions(exception: ProductOptimisticException): ResponseEntity<ErrorResponse<Unit>> {
log.warn("IllegalArgumentException : ${exception.message}")

return ResponseEntity.badRequest()
.body(ErrorResponse(HttpStatus.BAD_REQUEST.name, exception.message))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.example.productdomain.product.application.dto.ProductCreateInput
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull


data class ProductCreateRequest(
Expand All @@ -15,20 +16,22 @@ data class ProductCreateRequest(

@field:Min(0)
@field:Max(10_000_000_000)
val price: Int,
@field:NotNull
val price: Int?,

@field:Min(0)
@field:Max(10_000_000_000)
val quantity: Int,
@field:NotNull
val quantity: Int?,

@field:NotBlank
val memberNumber: String
) {
fun toInput(createdAudit: CreatedAudit): ProductCreateInput {
return ProductCreateInput(
name,
price,
quantity,
price!!,
quantity!!,
createdAudit,
UpdatedAudit(createdAudit.createdAt, createdAudit.createdBy)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ data class ProductEditRequest(

@field:Min(0)
@field:Max(10_000_000_000)
val price: Int,
@field:NotNull
val price: Int?,

@field:Min(0)
@field:Max(10_000_000_000)
val quantity: Int,
@field:NotNull
val quantity: Int?,

@field:NotNull
val status: ProductStatus,
Expand All @@ -27,16 +29,16 @@ data class ProductEditRequest(
val memberNumber: String,

@field:NotNull
val version: Long
val version: Long?
) {

fun toInput(): ProductEditInput {
return ProductEditInput(
name,
price,
quantity,
price!!,
quantity!!,
status,
version
version!!
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,25 @@ class ProductApplication(
) {

fun createProduct(request: ProductCreateRequest, createdAudit: CreatedAudit): Long {
validatePermission(productPermissionProperties.createPermissionId, createdAudit.createdBy)
require(productAdaptor.hasPermission(productPermissionProperties.createPermissionId, createdAudit.createdBy))
{ "사용자가 상품 생성 권한을 가지고 있지 않습니다: 멤버번호 ${createdAudit.createdBy}" }

return productCommandService.create(request.toInput(createdAudit)).id!!
}

fun editProduct(productId: Long, request: ProductEditRequest, updatedAudit: UpdatedAudit): ProductResponse {
validatePermission(productPermissionProperties.editPermissionId, updatedAudit.updatedBy)
require(productAdaptor.hasPermission(productPermissionProperties.editPermissionId, updatedAudit.updatedBy))
{ "사용자가 상품 수정 권한을 가지고 있지 않습니다: 멤버번호 ${updatedAudit.updatedBy}" }

val product = productCommandService.edit(productId, request.toInput(), updatedAudit)

return ProductResponse.from(product)
}

fun deleteProduct(productId: Long, updatedAudit: UpdatedAudit) {
validatePermission(productPermissionProperties.deletePermissionId, updatedAudit.updatedBy)
require(productAdaptor.hasPermission(productPermissionProperties.deletePermissionId, updatedAudit.updatedBy))
{ "사용자가 상품 삭제 권한을 가지고 있지 않습니다: 멤버번호 ${updatedAudit.updatedBy}" }

productCommandService.delete(productId, updatedAudit)
}

private fun validatePermission(permissionId: Long, memberNumber: String) {
require(productAdaptor.hasPermission(permissionId, memberNumber))
{ "사용자가 상품 권한을 가지고 있지 않습니다: 멤버번호 $memberNumber" }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.example.productapi.product.api
package com.example.productapi.product.api.controller

import com.example.productapi.product.api.controller.ProductController
import com.example.productapi.product.api.controller.RestDocsUtils.Companion.prettyDocument
import com.example.productapi.product.api.request.ProductCreateRequest
import com.example.productapi.product.api.request.ProductEditRequest
import com.example.productapi.product.api.request.ProductRequest
Expand All @@ -23,11 +23,7 @@ import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDoc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*
import org.springframework.restdocs.operation.preprocess.Preprocessors
import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest
import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse
import org.springframework.restdocs.payload.PayloadDocumentation.*
import org.springframework.restdocs.request.RequestDocumentation.parameterWithName
import org.springframework.restdocs.request.RequestDocumentation.pathParameters
Expand Down Expand Up @@ -79,10 +75,8 @@ class ProductControllerTest @Autowired constructor(
.andExpect(handler().methodName("createProduct"))
.andExpect(header().string("Location", "http://api.test.com:8080/v1/products/" + product.id))
.andDo(
document(
prettyDocument(
"product/create",
preprocessRequest(Preprocessors.prettyPrint()),
preprocessResponse(Preprocessors.prettyPrint()),
requestFields(
fieldWithPath("name").description("상품 이름"),
fieldWithPath("price").description("상품 가격"),
Expand Down Expand Up @@ -114,10 +108,8 @@ class ProductControllerTest @Autowired constructor(
.andExpect(handler().methodName("getProduct"))
.andExpect(content().string(objectMapper.writeValueAsString(ProductResponse.from(product))))
.andDo(
document(
prettyDocument(
"product/get",
preprocessRequest(Preprocessors.prettyPrint()),
preprocessResponse(Preprocessors.prettyPrint()),
pathParameters(parameterWithName("productId").description("상품 ID")),
responseFields(
fieldWithPath("productId").description("상품 ID"),
Expand Down Expand Up @@ -159,10 +151,8 @@ class ProductControllerTest @Autowired constructor(
.andExpect(handler().methodName("editProduct"))
.andExpect(content().string(objectMapper.writeValueAsString(ProductResponse.from(product))))
.andDo(
document(
prettyDocument(
"product/edit",
preprocessRequest(Preprocessors.prettyPrint()),
preprocessResponse(Preprocessors.prettyPrint()),
pathParameters(parameterWithName("productId").description("상품 ID")),
requestFields(
fieldWithPath("name").description("상품 이름"),
Expand Down Expand Up @@ -201,10 +191,8 @@ class ProductControllerTest @Autowired constructor(
.andExpect(status().isNoContent)
.andExpect(handler().methodName("deleteProduct"))
.andDo(
document(
prettyDocument(
"product/delete",
preprocessRequest(Preprocessors.prettyPrint()),
preprocessResponse(Preprocessors.prettyPrint()),
pathParameters(parameterWithName("productId").description("상품 ID")),
requestFields(
fieldWithPath("memberNumber").description("멤버 번호")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.example.productdomain.common.CreatedAudit
import com.example.productdomain.common.UpdatedAudit
import com.example.productdomain.product.application.ProductHistoryQueryService
import com.example.productdomain.product.domain.*
import com.fasterxml.jackson.databind.ObjectMapper
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import org.junit.jupiter.api.DisplayName
Expand All @@ -14,11 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get
import org.springframework.restdocs.operation.preprocess.Preprocessors
import org.springframework.restdocs.payload.PayloadDocumentation
import org.springframework.restdocs.request.RequestDocumentation
import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
import org.springframework.restdocs.payload.PayloadDocumentation.responseFields
import org.springframework.restdocs.request.RequestDocumentation.parameterWithName
import org.springframework.restdocs.request.RequestDocumentation.pathParameters
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
Expand Down Expand Up @@ -62,19 +61,17 @@ class ProductHistoryControllerTest @Autowired constructor(
.andExpect(jsonPath("$[0].createdBy").value("0000"))
.andExpect(jsonPath("$[0].createdAt").value("2024-03-09T00:00:00"))
.andDo(
MockMvcRestDocumentation.document(
RestDocsUtils.prettyDocument(
"product/product-histories",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
RequestDocumentation.pathParameters(
RequestDocumentation.parameterWithName("productId").description("상품 ID")
pathParameters(
parameterWithName("productId").description("상품 ID")
),
PayloadDocumentation.responseFields(
PayloadDocumentation.fieldWithPath("[].name").description("상품 이름"),
PayloadDocumentation.fieldWithPath("[].price").description("상품 가격"),
PayloadDocumentation.fieldWithPath("[].status").description("상품 상태"),
PayloadDocumentation.fieldWithPath("[].createdAt").description("수정 일자"),
PayloadDocumentation.fieldWithPath("[].createdBy").description("수정자")
responseFields(
fieldWithPath("[].name").description("상품 이름"),
fieldWithPath("[].price").description("상품 가격"),
fieldWithPath("[].status").description("상품 상태"),
fieldWithPath("[].createdAt").description("수정 일자"),
fieldWithPath("[].createdBy").description("수정자")
)
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.productapi.product.api.controller


import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler
import org.springframework.restdocs.operation.preprocess.Preprocessors
import org.springframework.restdocs.snippet.Snippet

class RestDocsUtils {

companion object {
fun prettyDocument(identifier: String, vararg snippets: Snippet): RestDocumentationResultHandler {
return document(
identifier,
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
*snippets
)
}
}
}
Loading

0 comments on commit 4e689da

Please sign in to comment.