Skip to content

Commit

Permalink
Merge pull request #201 from mash-up-kr/feature/duplicated-protecting
Browse files Browse the repository at this point in the history
feat: protect duplicated request
  • Loading branch information
KimDoubleB authored Sep 21, 2024
2 parents fa59b7b + a769641 commit 9047ba7
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 11 deletions.
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ spring-boot-annotation-processor = { group = "org.springframework.boot", name =
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" }
kotlin-junit5 = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit5" }
junit = { group = "org.junit.platform", name = "junit-platform-launcher" }
mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version = "5.4.0" }

[bundles]
spring-common = ["spring-boot-starter-autoconfigure"]
kotlin-spring = ["kotlin-refelct", "kotlin-jackson", "kotlin-logging"]
test-implementation = ["spring-boot-starter-test", "kotlin-junit5"]
test-implementation = ["spring-boot-starter-test", "kotlin-junit5", "mockito-kotlin"]
test-runtime = ["junit"]

bootstarp = ["spring-boot-starter-web", "spring-boot-starter-actuator", "opentelemetry-starter"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum class ExceptionCode(
ILLEGAL_ARGUMENT_EXCEPTION(400, "요청 값이 올바르지 않습니다."),
VOTE_PLACE_ID_INVALID(400, "투표 항목 데이터(Place Id)이 올바르지 않습니다."),
NOT_SUPPORT_AUTO_COMPLETE_URL(400, "자동입력 지원을 하지 않는 주소형식 입니다."),
DUPLICATED_REQUEST(400, "같은 리소스에 대한 중복요청입니다."),

UNAUTHORIZED(401, "인증된 토큰으로부터의 요청이 아닙니다."),
ROOM_PASSWORD_INVALID(401, "방 패스워드가 틀립니다."),
Expand All @@ -19,4 +20,5 @@ enum class ExceptionCode(
SECRET_MANAGER_CONFIG_NOT_SET(500, "시크릿 매니저 설정 값이 입력되지 않았습니다."),
URL_PROCESS_ERROR(500, "URL에 해당하는 장소의 정보를 불러오는 중 예기치 못한 오류가 발생했습니다."),
ROUTE_PROCESS_ERROR(500, "경로 거리 정보를 불러오는 중 예기치 못한 오류가 발생했습니다."),
REQUEST_TIMEOUT(500, "요청처리에 Timeout이 발생하였습니다."),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.piikii.input.http.aspect

import com.piikii.common.exception.ExceptionCode
import com.piikii.common.exception.PiikiiException
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.context.expression.MethodBasedEvaluationContext
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

/**
* 중복요청 방지 제어 Annotation
*
* @property key key to determine duplicate requests (support SpEL)
* @property timeoutMillis default 7 seconds
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class PreventDuplicateRequest(
val key: String,
val timeoutMillis: Long = 7_000,
)

@Aspect
@Component
class PreventDuplicateAspect {
private val processingRequests = ConcurrentHashMap.newKeySet<String>()
private val parser = SpelExpressionParser()
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()
private val executor = Executors.newVirtualThreadPerTaskExecutor()

@Around("@annotation(PreventDuplicateRequest)")
fun preventDuplicateRequest(joinPoint: ProceedingJoinPoint): Any? {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(PreventDuplicateRequest::class.java)
val key = generateKey(joinPoint, signature, annotation)

if (!processingRequests.add(key)) {
// if exists, throw Duplicated Request Exception
throw PiikiiException(ExceptionCode.DUPLICATED_REQUEST)
}

val future = executor.submit<Any> { joinPoint.proceed() }
try {
return future.get(annotation.timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: TimeoutException) {
future.cancel(true)
throw PiikiiException(ExceptionCode.REQUEST_TIMEOUT)
} finally {
processingRequests.remove(key)
}
}

/**
* 중복 요청으로 판단할 key 생성
* - method name + SpEL parsed value
*
* @param joinPoint
* @param signature
* @param annotation
* @return Key to determine duplicate requests
*/
private fun generateKey(
joinPoint: ProceedingJoinPoint,
signature: MethodSignature,
annotation: PreventDuplicateRequest,
): String {
val method = signature.method
val expression = parser.parseExpression(annotation.key)
val context =
MethodBasedEvaluationContext(
joinPoint.target,
method,
joinPoint.args,
parameterNameDiscoverer,
)
val parsedValue =
expression.getValue(context, String::class.java)
?: throw PiikiiException(
ExceptionCode.NOT_FOUNDED,
"중복요청 계산에 사용될 key가 없습니다 (method: ${method.name}, annotation key: ${annotation.key})",
)
return "${method.name}_$parsedValue"
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.piikii.input.http.web.config
package com.piikii.input.http.config

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.piikii.application.port.input.dto.request.AddPlaceRequest
import com.piikii.application.port.input.dto.request.ModifyPlaceRequest
import com.piikii.application.port.input.dto.response.PlaceResponse
import com.piikii.application.port.input.dto.response.ScheduleTypeGroupResponse
import com.piikii.input.http.aspect.PreventDuplicateRequest
import com.piikii.input.http.controller.docs.PlaceDocs
import com.piikii.input.http.controller.dto.ResponseForm
import jakarta.validation.Valid
Expand All @@ -35,6 +36,7 @@ class PlaceApi(
private val placeUseCase: PlaceUseCase,
private val imageUploadUseCase: ImageUploadUseCase,
) : PlaceDocs {
@PreventDuplicateRequest("#roomUid + #addPlaceRequest.name")
@ResponseStatus(HttpStatus.CREATED)
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
override fun addPlace(
Expand All @@ -54,6 +56,7 @@ class PlaceApi(
return ResponseForm(placeUseCase.findAllByRoomUidGroupByPlaceType(UuidTypeId(roomUid)))
}

@PreventDuplicateRequest("#roomUid + #placeId")
@ResponseStatus(HttpStatus.OK)
@PatchMapping("/{placeId}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
override fun modifyPlace(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.piikii.application.port.input.dto.request.RoomSaveRequestForm
import com.piikii.application.port.input.dto.request.RoomUpdateRequestForm
import com.piikii.application.port.input.dto.response.RoomResponse
import com.piikii.application.port.input.dto.response.SaveRoomResponse
import com.piikii.input.http.aspect.PreventDuplicateRequest
import com.piikii.input.http.controller.docs.RoomApiDocs
import com.piikii.input.http.controller.dto.ResponseForm
import jakarta.validation.Valid
Expand Down Expand Up @@ -34,17 +35,18 @@ class RoomApi(
) : RoomApiDocs {
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
override fun create(
override fun createRoom(
@Valid @NotNull @RequestBody request: RoomSaveRequestForm,
): ResponseForm<SaveRoomResponse> {
return ResponseForm(
data = roomUseCase.create(request),
)
}

@PreventDuplicateRequest("#request.roomUid")
@ResponseStatus(HttpStatus.OK)
@PutMapping
override fun modifyInformation(
override fun modifyRoom(
@Valid @NotNull @RequestBody request: RoomUpdateRequestForm,
): ResponseForm<Unit> {
roomUseCase.modify(request)
Expand All @@ -53,7 +55,7 @@ class RoomApi(

@ResponseStatus(HttpStatus.OK)
@DeleteMapping("/{roomUid}")
override fun remove(
override fun deleteRoom(
@NotNull @PathVariable roomUid: UUID,
): ResponseForm<Unit> {
roomUseCase.remove(UuidTypeId(roomUid))
Expand All @@ -62,7 +64,7 @@ class RoomApi(

@ResponseStatus(HttpStatus.OK)
@GetMapping("/{roomUid}")
override fun search(
override fun retrieveRoom(
@NotNull @PathVariable roomUid: UUID,
): ResponseForm<RoomResponse> {
return ResponseForm(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.piikii.application.port.input.dto.request.VoteSaveRequest
import com.piikii.application.port.input.dto.response.VoteResultResponse
import com.piikii.application.port.input.dto.response.VoteStatusResponse
import com.piikii.application.port.input.dto.response.VotedPlacesResponse
import com.piikii.input.http.aspect.PreventDuplicateRequest
import com.piikii.input.http.controller.docs.VoteApiDocs
import com.piikii.input.http.controller.dto.ResponseForm
import jakarta.validation.Valid
Expand All @@ -31,6 +32,7 @@ class VoteApi(
private val voteUseCase: VoteUseCase,
private val roomUseCase: RoomUseCase,
) : VoteApiDocs {
@PreventDuplicateRequest("#roomUid")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PatchMapping("/deadline")
override fun changeVoteDeadline(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.piikii.input.http.web.advice
package com.piikii.input.http.controller.advice

import com.piikii.common.exception.PiikiiException
import com.piikii.common.logutil.SlackHookLogger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface RoomApiDocs {
),
],
)
fun create(
fun createRoom(
@Parameter(
description = "방 생성 요청 정보",
required = true,
Expand All @@ -58,7 +58,7 @@ interface RoomApiDocs {
),
],
)
fun modifyInformation(
fun modifyRoom(
@Parameter(
description = "방 수정 요청 정보",
required = true,
Expand All @@ -80,7 +80,7 @@ interface RoomApiDocs {
),
],
)
fun remove(
fun deleteRoom(
@Parameter(
name = "roomUid",
description = "삭제하고자 하는 방 id",
Expand All @@ -99,7 +99,7 @@ interface RoomApiDocs {
),
],
)
fun search(
fun retrieveRoom(
@Parameter(
name = "roomUid",
description = "조회하고자 하는 방 id",
Expand Down

0 comments on commit 9047ba7

Please sign in to comment.