diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt new file mode 100644 index 00000000..03dbe179 --- /dev/null +++ b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt @@ -0,0 +1,75 @@ +package com.sangdol.common.web.support.log + +import com.sangdol.common.log.constant.LogType +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.utils.MdcStartTimeUtil +import jakarta.servlet.http.HttpServletRequest + +class LogPayloadBuilder( + private val type: LogType, + private val servletRequest: HttpServletRequest, + private val payload: MutableMap = mutableMapOf("type" to type) +) { + fun endpoint(): LogPayloadBuilder { + payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}" + return this + } + + fun clientIp(): LogPayloadBuilder { + servletRequest.remoteAddr?.let { payload["client_ip"] = it } + return this + } + + fun userAgent(): LogPayloadBuilder { + servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it } + return this + } + + fun queryString(): LogPayloadBuilder { + servletRequest.queryString?.let { payload["query_params"] = it } + return this + } + + fun httpStatus(statusCode: Int?): LogPayloadBuilder { + statusCode?.let { payload["status_code"] = it } + + return this + } + + fun responseBody(body: Any?): LogPayloadBuilder { + body?.let { payload["response_body"] = it } + + return this + } + + fun durationMs(): LogPayloadBuilder { + MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it } + return this + } + + fun principalId(): LogPayloadBuilder { + MdcPrincipalIdUtil.extractAsLongOrNull() + ?.let { payload["principal_id"] = it } + ?: run { payload["principal_id"] = "UNKNOWN" } + + return this + } + + fun exception(exception: Exception?): LogPayloadBuilder { + exception?.let { + payload["exception"] = mapOf( + "class" to it.javaClass.simpleName, + "message" to it.message + ) + } + + return this + } + + fun additionalPayloads(payload: Map): LogPayloadBuilder { + this.payload.putAll(payload) + return this + } + + fun build(): Map = payload +} diff --git a/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt new file mode 100644 index 00000000..0b905efa --- /dev/null +++ b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt @@ -0,0 +1,233 @@ +package com.sangdol.common.web.support.log + +import com.sangdol.common.log.constant.LogType +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.utils.MdcStartTimeUtil +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import jakarta.servlet.http.HttpServletRequest + +class LogPayloadBuilderTest : FunSpec({ + val servletRequest: HttpServletRequest = mockk() + + lateinit var method: String + lateinit var requestUri: String + lateinit var remoteAddr: String + lateinit var userAgent: String + lateinit var queryString: String + + beforeTest { + method = "GET".also { every { servletRequest.method } returns it } + requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it } + remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it } + userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it } + queryString = "key=value".also { every { servletRequest.queryString } returns it } + } + + afterSpec { + clearMocks(servletRequest) + } + + context("endpoint") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .endpoint() + .build() + + result["endpoint"] shouldBe "$method $requestUri" + } + + test("ServletRequest에서 null이 반환되면 그대로 들어간다.") { + every { servletRequest.method } returns null + every { servletRequest.requestURI } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .endpoint() + .build() + + result["endpoint"] shouldBe "null null" + } + } + + context("clientIp") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .clientIp() + .build() + + result["client_ip"] shouldBe remoteAddr + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.remoteAddr } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .clientIp() + .build() + + result["client_ip"] shouldBe null + } + } + + context("userAgent") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .userAgent() + .build() + + result["user_agent"] shouldBe userAgent + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.getHeader("User-Agent") } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .userAgent() + .build() + + result["user_agent"] shouldBe null + } + } + + context("queryString") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .queryString() + .build() + + result["query_params"] shouldBe queryString + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.queryString } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .queryString() + .build() + + result["query_params"] shouldBe null + } + } + + context("httpStatus") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .httpStatus(200) + .build() + + result["status_code"] shouldBe 200 + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .httpStatus(null) + .build() + + result["status_code"] shouldBe null + } + } + + context("responseBody") { + test("정상 응답") { + val body = mapOf("key" to "value") + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .responseBody(body) + .build() + + result["response_body"] shouldBe body + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .responseBody(null) + .build() + + result["response_body"] shouldBe null + } + } + + context("durationMs") { + test("정상 응답") { + MdcStartTimeUtil.setCurrentTime() + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .durationMs() + .build() + + result["duration_ms"].shouldNotBeNull() + MdcStartTimeUtil.clear() + } + + test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .durationMs() + .build() + + result["duration_ms"] shouldBe null + } + } + + context("principalId") { + test("정상 응답") { + val principalId = 759980174446956066L.also { + MdcPrincipalIdUtil.set(it.toString()) + } + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .principalId() + .build() + + result["principal_id"] shouldBe principalId + MdcPrincipalIdUtil.clear() + } + + test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .principalId() + .build() + + result["principal_id"] shouldBe "UNKNOWN" + } + } + + context("exception") { + test("정상 응답") { + val exception = RuntimeException("hello") + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .exception(exception) + .build() + + result["exception"] shouldBe mapOf( + "class" to exception.javaClass.simpleName, + "message" to exception.message + ) + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .exception(null) + .build() + + result["exception"] shouldBe null + } + } + + context("additionalPayloads") { + test("정상 응답") { + val payload = mapOf( + "key1" to "value1", + "key2" to "value2" + ) + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .additionalPayloads(payload) + .build() + + result["key1"] shouldBe "value1" + result["key2"] shouldBe "value2" + } + } +})