Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package team.cklob.arena.global.common

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.util.UUID

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class RequestLoggingFilter : OncePerRequestFilter() {
private val log = LoggerFactory.getLogger(javaClass)

override fun shouldNotFilter(request: HttpServletRequest): Boolean = EXCLUDED_PATH_PREFIXES.any { request.requestURI.startsWith(it) }
Comment thread
cfcromn marked this conversation as resolved.
Outdated

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val requestId = UUID.randomUUID().toString()
val startedAt = System.nanoTime()

MDC.put(REQUEST_ID_KEY, requestId)
response.setHeader(REQUEST_ID_HEADER, requestId)

try {
filterChain.doFilter(request, response)
} finally {
val elapsedMillis = (System.nanoTime() - startedAt) / NANOS_PER_MILLISECOND
log.info(
"requestId={} method={} path={} status={} elapsedMs={}",
requestId,
request.method,
request.requestURI,
response.status,
elapsedMillis,
)
MDC.remove(REQUEST_ID_KEY)
}
}

companion object {
const val REQUEST_ID_HEADER = "X-Request-Id"

private const val REQUEST_ID_KEY = "requestId"
private const val NANOS_PER_MILLISECOND = 1_000_000
private val EXCLUDED_PATH_PREFIXES = listOf("/swagger-ui", "/v3/api-docs", "/actuator")
}
}
17 changes: 17 additions & 0 deletions src/main/kotlin/team/cklob/arena/global/config/JacksonConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package team.cklob.arena.global.config

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.SerializationFeature
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class JacksonConfig {
@Bean
fun jacksonCustomizer(): Jackson2ObjectMapperBuilderCustomizer =
Jackson2ObjectMapperBuilderCustomizer {
it.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
it.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum class CommonErrorCode(
override val message: String,
) : ErrorCode {
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
UNKNOWN_JSON_FIELD(HttpStatus.BAD_REQUEST, "알 수 없는 요청 필드입니다."),
MALFORMED_JSON(HttpStatus.BAD_REQUEST, "요청 본문 형식이 올바르지 않습니다."),
INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "요청 값의 형식이 올바르지 않습니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package team.cklob.arena.global.exception

import com.fasterxml.jackson.databind.exc.InvalidFormatException
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
Comment thread
cfcromn marked this conversation as resolved.
import jakarta.validation.ConstraintViolationException
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
Expand Down Expand Up @@ -52,13 +53,16 @@ class GlobalExceptionHandler {

@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleUnreadableMessage(exception: HttpMessageNotReadableException): ResponseEntity<CommonApiResponse<Any>> =
response(
if (exception.cause is InvalidFormatException) {
CommonErrorCode.INVALID_TYPE_VALUE
} else {
CommonErrorCode.MALFORMED_JSON
},
)
when (val cause = exception.cause) {
is UnrecognizedPropertyException ->
response(
errorCode = CommonErrorCode.UNKNOWN_JSON_FIELD,
data = ValidationErrorData(listOf(FieldErrorDetail(cause.propertyName, "알 수 없는 필드입니다."))),
)

is InvalidFormatException -> response(CommonErrorCode.INVALID_TYPE_VALUE)
else -> response(CommonErrorCode.MALFORMED_JSON)
}
Comment thread
cfcromn marked this conversation as resolved.

@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity<CommonApiResponse<Any>> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class CommonResponseAdvice : ResponseBodyAdvice<Any> {
converterType: Class<out HttpMessageConverter<*>>,
): Boolean =
returnType.parameterType != String::class.java &&
returnType.parameterType != ByteArray::class.java &&
!ResponseEntity::class.java.isAssignableFrom(returnType.parameterType)
Comment thread
cfcromn marked this conversation as resolved.

override fun beforeBodyWrite(
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ spring:
application:
name: arena

springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html

security:
jwt:
# Base64-encoded secret with at least 32 bytes before encoding.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package team.cklob.arena.global.security

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.matchers.shouldBe
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
Expand All @@ -17,6 +18,9 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import team.cklob.arena.global.common.RequestLoggingFilter
import java.time.LocalDateTime
import java.util.UUID

@SpringBootTest
@AutoConfigureMockMvc
Expand All @@ -28,6 +32,22 @@ class SecurityIntegrationTest(
extension(SpringExtension)

describe("공통 응답 및 보안 설정") {
it("서버 생성 request ID를 응답 헤더에 반환한다") {
val result =
mockMvc
.get("/auth/test") {
header(RequestLoggingFilter.REQUEST_ID_HEADER, "client-request-id")
accept = MediaType.APPLICATION_JSON
}.andExpect {
status { isOk() }
header { exists(RequestLoggingFilter.REQUEST_ID_HEADER) }
}.andReturn()

val requestId = result.response.getHeader(RequestLoggingFilter.REQUEST_ID_HEADER)
(requestId == "client-request-id") shouldBe false
runCatching { UUID.fromString(requireNotNull(requestId)) }.isSuccess shouldBe true
}

it("Bearer가 아닌 Authorization 헤더는 공개 API를 차단하지 않는다") {
mockMvc.get("/auth/test") {
header("Authorization", "Basic ignored")
Expand Down Expand Up @@ -88,6 +108,32 @@ class SecurityIntegrationTest(
jsonPath("$.data.fieldErrors[0].field") { value("name") }
}
}

it("알 수 없는 JSON 필드를 요청 오류로 반환한다") {
mockMvc.post("/auth/test") {
contentType = MediaType.APPLICATION_JSON
content = """{"name":"arena","unknown":true}"""
}.andExpect {
status { isBadRequest() }
jsonPath("$.code") { value("UNKNOWN_JSON_FIELD") }
jsonPath("$.data.fieldErrors[0].field") { value("unknown") }
}
}

it("시간 값을 ISO-8601 문자열로 직렬화한다") {
mockMvc.get("/auth/time") {
accept = MediaType.APPLICATION_JSON
}.andExpect {
status { isOk() }
jsonPath("$.data.createdAt") { value("2026-01-02T03:04:05") }
}
}

it("OpenAPI 문서는 인증 없이 접근할 수 있다") {
mockMvc.get("/v3/api-docs").andExpect {
status { isOk() }
}
}
}
}) {
override fun extensions() = listOf(SpringExtension)
Expand All @@ -106,6 +152,9 @@ class SecurityIntegrationTest(
fun validate(
@Valid @RequestBody request: TestRequest,
) = mapOf("name" to request.name)

@GetMapping("/auth/time")
fun time() = mapOf("createdAt" to LocalDateTime.of(2026, 1, 2, 3, 4, 5))
}

data class TestRequest(
Expand Down
Loading