Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: protect duplicated request #201

Merged
merged 5 commits into from
Sep 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
KimDoubleB marked this conversation as resolved.
Show resolved Hide resolved
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()
K-Diger marked this conversation as resolved.
Show resolved Hide resolved

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