diff --git a/rush-config/src/main/resources/config/rush-gateway.yml b/rush-config/src/main/resources/config/rush-gateway.yml index 9888f15..bdb849e 100644 --- a/rush-config/src/main/resources/config/rush-gateway.yml +++ b/rush-config/src/main/resources/config/rush-gateway.yml @@ -9,6 +9,9 @@ gateway: trusted-proxies: ${TRUSTED_PROXIES:} # CORS를 허용할 Origin(콤마 구분). 비어 있으면 CORS를 허용하지 않습니다. cors-allowed-origins: ${CORS_ALLOWED_ORIGINS:} + response-wrapper: + # 이보다 큰 서비스 응답은 공통 형식으로 감싸지 않고 원문을 보냅니다. + max-size: 1MB # 공통 application.yml의 exposure를 덮어씁니다. health 외에는 허용 IP에서만 열립니다. management: diff --git a/rush-gateway/README.md b/rush-gateway/README.md index f372c68..6d6c9ec 100644 --- a/rush-gateway/README.md +++ b/rush-gateway/README.md @@ -15,12 +15,20 @@ Spring Cloud Gateway. 외부 요청을 받는 유일한 입구이며, JWT 검증 WebSocket을 제외한 라우트는 서킷브레이커(Resilience4j)를 거칩니다. 연결 실패와 서비스의 500·502·503·504 응답을 실패로 집계합니다. 최근 20건 중 절반이 실패하면 10초 동안 차단합니다. 응답은 원인별로 나뉩니다. 5초 안에 응답이 없으면 `504`, 서비스가 5xx를 돌려주면 `502`, 차단 중이거나 연결할 수 없으면 `503`입니다. -게이트웨이가 만드는 에러는 모두 같은 형식입니다. +## 공통 응답 + +클라이언트가 받는 JSON 응답은 모두 `{status, code, message, data}` 형식이며, `data`가 없으면 생략합니다. ```json -{ "code": "UNAUTHORIZED", "message": "인증이 필요합니다." } +{ "status": 200, "code": "OK", "message": "요청에 성공했습니다.", "data": { "id": 1 } } +{ "status": 401, "code": "UNAUTHORIZED", "message": "인증이 필요합니다." } ``` +- 게이트웨이가 직접 만드는 오류(400·401·403·404·5xx·fallback)는 처음부터 이 형식입니다. +- 서비스의 JSON 응답은 `ApiResponseWrapperFilter`가 감쌉니다. 성공 응답은 원본을 `data`에 넣습니다. 오류 응답은 원본의 `code`, `message`를 쓰고, 없으면 상태 이름과 reason phrase를 씁니다. +- 이미 `status`, `code`, `message`를 모두 가진 응답, JSON이 아닌 응답, 압축된 응답, 204·304, HEAD, WebSocket, actuator·fallback·Swagger 경로는 감싸지 않습니다. +- 1MB(`gateway.response-wrapper.max-size`)를 넘는 응답은 감싸지 않고 원문을 그대로 보냅니다. + 모든 요청에 `X-Request-Id`를 새로 붙여 서비스와 응답에 넘기고, 메서드·경로·상태·소요 시간·사용자 ID를 한 줄로 남깁니다. ## 접근 제어 diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt index 975667b..a8a53dc 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponseWriter.kt @@ -1,6 +1,7 @@ package com.rush.gateway.error import com.fasterxml.jackson.databind.ObjectMapper +import com.rush.gateway.response.CommonApiResponse import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.stereotype.Component @@ -18,7 +19,7 @@ class ErrorResponseWriter( val response = exchange.response response.statusCode = status response.headers.contentType = MediaType.APPLICATION_JSON - val body = objectMapper.writeValueAsBytes(ErrorResponse.of(status)) + val body = objectMapper.writeValueAsBytes(CommonApiResponse.error(status)) return response.writeWith(Mono.just(response.bufferFactory().wrap(body))) } } diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt index 259c93c..8c15d2c 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/fallback/FallbackController.kt @@ -1,6 +1,6 @@ package com.rush.gateway.fallback -import com.rush.gateway.error.ErrorResponse +import com.rush.gateway.response.CommonApiResponse import org.slf4j.LoggerFactory import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerFilterFactory.CircuitBreakerStatusCodeException import org.springframework.cloud.gateway.route.Route @@ -18,7 +18,7 @@ class FallbackController { private val log = LoggerFactory.getLogger(javaClass) @RequestMapping("/fallback") - fun fallback(exchange: ServerWebExchange): ResponseEntity { + fun fallback(exchange: ServerWebExchange): ResponseEntity { val cause = exchange.getAttribute(CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR) val status = statusOf(cause) log.warn( @@ -27,7 +27,7 @@ class FallbackController { status.value(), cause.toString(), ) - return ResponseEntity.status(status).body(ErrorResponse.of(status)) + return ResponseEntity.status(status).body(CommonApiResponse.error(status)) } // 응답 지연은 504, 서비스가 5xx를 돌려줬으면 502, 차단기가 열렸거나 연결에 실패했으면 503입니다. diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseBodyWrapper.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseBodyWrapper.kt new file mode 100644 index 0000000..1d17ede --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseBodyWrapper.kt @@ -0,0 +1,52 @@ +package com.rush.gateway.response + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.cfg.JsonNodeFeature +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +/** + * 서비스가 돌려준 JSON body를 공통 형식으로 바꿉니다. + */ +@Component +class ApiResponseBodyWrapper( + private val objectMapper: ObjectMapper, +) { + // 소수를 double로 읽으면 금액 같은 값의 자릿수가 바뀌므로 BigDecimal로 읽고, 끝자리 0도 원문대로 둡니다. + private val reader = + objectMapper + .reader() + .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .without(JsonNodeFeature.STRIP_TRAILING_BIGDECIMAL_ZEROES) + + /** 감싼 body를 돌려줍니다. 이미 공통 형식이거나 JSON으로 읽을 수 없으면 null입니다. */ + fun wrap( + status: HttpStatus, + body: ByteArray, + ): ByteArray? { + val node = if (body.isEmpty()) null else runCatching { reader.readTree(body) }.getOrElse { return null } + if (node != null && isAlreadyWrapped(node)) return null + + val wrapped = + if (status.isError) { + CommonApiResponse.error( + status = status.value(), + code = node.textField("code") ?: status.name, + message = node.textField("message") ?: status.reasonPhrase, + ) + } else { + CommonApiResponse.success(status, node) + } + return objectMapper.writeValueAsBytes(wrapped) + } + + private fun isAlreadyWrapped(node: JsonNode) = node.isObject && WRAPPED_KEYS.all(node::has) + + private fun JsonNode?.textField(name: String): String? = this?.get(name)?.takeIf { it.isTextual }?.asText() + + companion object { + private val WRAPPED_KEYS = listOf("status", "code", "message") + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperFilter.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperFilter.kt new file mode 100644 index 0000000..7ca4166 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperFilter.kt @@ -0,0 +1,88 @@ +package com.rush.gateway.response + +import org.reactivestreams.Publisher +import org.springframework.cloud.gateway.filter.GatewayFilterChain +import org.springframework.cloud.gateway.filter.GlobalFilter +import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter +import org.springframework.core.Ordered +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferUtils +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.server.reactive.ServerHttpResponseDecorator +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +/** + * 서비스 응답을 CommonApiResponse로 감쌉니다. + * + * NettyWriteResponseFilter 바로 앞에서 응답을 가로채, 쓰기 직전에 [ApiResponseWrappingPolicy]로 감쌀지 정합니다. + * 감쌀 때는 body를 최대 크기까지 모았다가 바꾸고, 크기를 넘으면 원문을 그대로 흘려보냅니다. + * SSE처럼 writeAndFlushWith로 나가는 스트리밍 응답은 감싸지 않습니다. + */ +@Component +class ApiResponseWrapperFilter( + private val policy: ApiResponseWrappingPolicy, + private val bodyWrapper: ApiResponseBodyWrapper, + properties: ApiResponseWrapperProperties, +) : GlobalFilter, + Ordered { + private val maxBytes = properties.maxSize.toBytes() + + override fun filter( + exchange: ServerWebExchange, + chain: GatewayFilterChain, + ): Mono = chain.filter(exchange.mutate().response(WrappingResponse(exchange)).build()) + + private inner class WrappingResponse( + private val exchange: ServerWebExchange, + ) : ServerHttpResponseDecorator(exchange.response) { + override fun writeWith(body: Publisher): Mono { + val request = exchange.request + val decision = policy.decide(request.path.value(), request.method, request.headers, httpStatus(), headers) + if (decision != ApiResponseWrappingPolicy.Decision.WRAP) return super.writeWith(body) + + val collector = BoundedBodyCollector(maxBytes) + val relayed = Flux.from(body).concatMapIterable(collector::accept) + val wrapped = Mono.defer { if (collector.relaying) Mono.empty() else Mono.justOrEmpty(wrappedBody(collector.drain())) } + return super.writeWith( + relayed + .concatWith(wrapped) + .doOnCancel(collector::release) + .doOnError { collector.release() }, + ) + } + + private fun httpStatus(): HttpStatus? = statusCode?.let { HttpStatus.resolve(it.value()) } + + // 모은 버퍼를 하나로 합쳐 감싸고, 감쌀 수 없으면 원문 그대로 돌려줍니다. + private fun wrappedBody(buffers: List): DataBuffer? { + if (buffers.isEmpty()) return null + val original = + bufferFactory().join(buffers).let { joined -> + ByteArray(joined.readableByteCount()).also { + joined.read(it) + DataBufferUtils.release(joined) + } + } + val status = httpStatus() ?: HttpStatus.OK + val bytes = bodyWrapper.wrap(status, original) ?: return bufferFactory().wrap(original) + + headers.remove(HttpHeaders.TRANSFER_ENCODING) + HEADERS_INVALIDATED_BY_WRAPPING.forEach(headers::remove) + headers.contentType = MediaType.APPLICATION_JSON + headers.contentLength = bytes.size.toLong() + return bufferFactory().wrap(bytes) + } + } + + override fun getOrder() = NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1 + + companion object { + // body가 바뀌면 의미가 없어지는 헤더 + private val HEADERS_INVALIDATED_BY_WRAPPING = listOf(HttpHeaders.ETAG, "Content-MD5", "Digest") + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperProperties.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperProperties.kt new file mode 100644 index 0000000..c8425e7 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrapperProperties.kt @@ -0,0 +1,15 @@ +package com.rush.gateway.response + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.util.unit.DataSize + +/** + * 서비스 응답 래핑 설정입니다. + */ +@ConfigurationProperties("gateway.response-wrapper") +data class ApiResponseWrapperProperties( + // 이보다 큰 응답은 감싸지 않고 원문을 그대로 보냅니다. + val maxSize: DataSize = DataSize.ofMegabytes(1), + // 기본 제외 경로에 더해 감싸지 않을 경로 패턴 + val excludedPaths: List = emptyList(), +) diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicy.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicy.kt new file mode 100644 index 0000000..68839b5 --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicy.kt @@ -0,0 +1,75 @@ +package com.rush.gateway.response + +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.server.PathContainer +import org.springframework.stereotype.Component +import org.springframework.web.util.pattern.PathPatternParser + +/** + * 서비스 응답을 공통 형식으로 감쌀지 정합니다. 응답 상태와 헤더가 정해진 뒤, body를 쓰기 직전에 판단합니다. + */ +@Component +class ApiResponseWrappingPolicy( + properties: ApiResponseWrapperProperties, +) { + private val maxBytes = properties.maxSize.toBytes() + private val excludedPatterns = (DEFAULT_EXCLUDED_PATHS + properties.excludedPaths).map(PathPatternParser.defaultInstance::parse) + + fun decide( + path: String, + method: HttpMethod, + requestHeaders: HttpHeaders, + status: HttpStatus?, + responseHeaders: HttpHeaders, + ): Decision { + val pathContainer = PathContainer.parsePath(path) + return when { + excludedPatterns.any { it.matches(pathContainer) } -> Decision.SKIP_EXCLUDED_PATH + requestHeaders.upgrade.equals("websocket", ignoreCase = true) -> Decision.SKIP_WEBSOCKET + method == HttpMethod.HEAD -> Decision.SKIP_NO_BODY + status == HttpStatus.NO_CONTENT || status == HttpStatus.NOT_MODIFIED -> Decision.SKIP_NO_BODY + // Content-Range는 원본 본문 기준이라 감싸면 범위가 맞지 않습니다. + status == HttpStatus.PARTIAL_CONTENT || responseHeaders.containsKey(HttpHeaders.CONTENT_RANGE) -> Decision.SKIP_PARTIAL + isEncoded(responseHeaders) -> Decision.SKIP_ENCODED + !isJson(responseHeaders.contentType) -> Decision.SKIP_NOT_JSON + responseHeaders.contentLength > maxBytes -> Decision.SKIP_TOO_LARGE + else -> Decision.WRAP + } + } + + private fun isEncoded(headers: HttpHeaders): Boolean { + val encoding = headers.getFirst(HttpHeaders.CONTENT_ENCODING) ?: return false + return !encoding.equals("identity", ignoreCase = true) + } + + private fun isJson(contentType: MediaType?): Boolean = + contentType != null && + (contentType.isCompatibleWith(MediaType.APPLICATION_JSON) || contentType.subtype.endsWith("+json")) + + enum class Decision { + WRAP, + SKIP_EXCLUDED_PATH, + SKIP_WEBSOCKET, + SKIP_NO_BODY, + SKIP_PARTIAL, + SKIP_ENCODED, + SKIP_NOT_JSON, + SKIP_TOO_LARGE, + } + + companion object { + val DEFAULT_EXCLUDED_PATHS = + listOf( + "/actuator/**", + "/fallback", + "/v3/api-docs/**", + "/*/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/webjars/**", + ) + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/response/BoundedBodyCollector.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/BoundedBodyCollector.kt new file mode 100644 index 0000000..fd3f27d --- /dev/null +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/BoundedBodyCollector.kt @@ -0,0 +1,39 @@ +package com.rush.gateway.response + +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferUtils + +/** + * 응답 body를 최대 [maxBytes]까지 모읍니다. 넘는 순간 모은 버퍼를 한꺼번에 내보내고, + * 이후 버퍼는 모으지 않고 그대로 통과시킵니다. 요청 하나에서만 쓰는 상태 객체입니다. + */ +class BoundedBodyCollector( + private val maxBytes: Long, +) { + private val collected = mutableListOf() + private var size = 0L + + /** 크기를 넘어 원문을 그대로 흘려보내는 중인지 여부입니다. */ + var relaying = false + private set + + /** 버퍼 하나를 받아, 지금 내보낼 버퍼 목록을 돌려줍니다. 모으는 중이면 빈 목록입니다. */ + fun accept(buffer: DataBuffer): List { + if (relaying) return listOf(buffer) + + collected += buffer + size += buffer.readableByteCount() + if (size <= maxBytes) return emptyList() + + relaying = true + return drain() + } + + /** 모은 버퍼를 넘겨주고 비웁니다. 넘겨받은 쪽이 해제합니다. */ + fun drain(): List = collected.toList().also { collected.clear() } + + /** 취소나 오류로 끝났을 때 아직 들고 있는 버퍼를 해제합니다. */ + fun release() { + drain().forEach(DataBufferUtils::release) + } +} diff --git a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt b/rush-gateway/src/main/kotlin/com/rush/gateway/response/CommonApiResponse.kt similarity index 52% rename from rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt rename to rush-gateway/src/main/kotlin/com/rush/gateway/response/CommonApiResponse.kt index a0e6669..1c6cf84 100644 --- a/rush-gateway/src/main/kotlin/com/rush/gateway/error/ErrorResponse.kt +++ b/rush-gateway/src/main/kotlin/com/rush/gateway/response/CommonApiResponse.kt @@ -1,13 +1,34 @@ -package com.rush.gateway.error +package com.rush.gateway.response +import com.fasterxml.jackson.annotation.JsonInclude import org.springframework.http.HttpStatus -data class ErrorResponse( +/** + * 클라이언트가 받는 공통 응답 형식입니다. 서비스 응답은 ApiResponseWrapperFilter가, + * 게이트웨이가 직접 만드는 오류는 ErrorResponseWriter가 이 형식으로 씁니다. data가 없으면 필드를 생략합니다. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CommonApiResponse( + val status: Int, val code: String, val message: String, + val data: Any? = null, ) { companion object { - fun of(status: HttpStatus) = ErrorResponse(status.name, messageOf(status)) + const val SUCCESS_MESSAGE = "요청에 성공했습니다." + + fun success( + status: HttpStatus, + data: Any?, + ) = CommonApiResponse(status.value(), status.name, SUCCESS_MESSAGE, data) + + fun error(status: HttpStatus) = error(status.value(), status.name, messageOf(status)) + + fun error( + status: Int, + code: String, + message: String, + ) = CommonApiResponse(status, code, message) private fun messageOf(status: HttpStatus) = when (status) { diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrapperTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrapperTest.kt new file mode 100644 index 0000000..cf50f71 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrapperTest.kt @@ -0,0 +1,187 @@ +package com.rush.gateway.response + +import com.sun.net.httpserver.HttpServer +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.reactive.server.WebTestClient +import java.net.InetSocketAddress + +private const val SECRET = "test-secret-key-for-gateway-wrapper-test-0123456789" +private const val MAX_BYTES = 1024 + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "jwt.secret=$SECRET", + "gateway.response-wrapper.max-size=${MAX_BYTES}B", + ], +) +@AutoConfigureWebTestClient +class ApiResponseWrapperTest { + @Autowired + lateinit var client: WebTestClient + + private fun get(path: String) = + client + .get() + .uri("/user$path") + .header("Authorization", "Bearer ${token()}") + .exchange() + + @Test + fun `성공 JSON은 data로 감싼다`() { + val result = + get("/item") + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.status") + .isEqualTo(200) + .jsonPath("$.code") + .isEqualTo("OK") + .jsonPath("$.message") + .isEqualTo(CommonApiResponse.SUCCESS_MESSAGE) + .jsonPath("$.data.id") + .isEqualTo(1) + .jsonPath("$.data.name") + .isEqualTo("사과") + .returnResult() + + // 감싼 뒤에도 Content-Length가 실제 길이와 같아야 합니다. + assertThat(result.responseHeaders.contentLength).isEqualTo(result.responseBody!!.size.toLong()) + assertThat(result.responseHeaders.eTag).isNull() + } + + @Test + fun `배열 응답도 data로 감싼다`() { + get("/list") + .expectBody() + .jsonPath("$.data[0]") + .isEqualTo(1) + .jsonPath("$.data.length()") + .isEqualTo(3) + } + + @Test + fun `오류 JSON은 상태와 서비스의 메시지로 감싼다`() { + get("/missing") + .expectStatus() + .isNotFound + .expectBody() + .jsonPath("$.status") + .isEqualTo(404) + .jsonPath("$.code") + .isEqualTo("ITEM_NOT_FOUND") + .jsonPath("$.message") + .isEqualTo("상품이 없습니다.") + .jsonPath("$.data") + .doesNotExist() + } + + @Test + fun `소수는 원문 자릿수 그대로 감싼다`() { + val body = get("/decimal").expectBody(String::class.java).returnResult().responseBody!! + + assertThat(body).contains(""""data":{"amount":1234567890.123456789,"price":1.50}""") + } + + @Test + fun `부분 응답은 Content-Range와 함께 그대로 둔다`() { + get("/partial") + .expectStatus() + .isEqualTo(206) + .expectHeader() + .valueEquals("Content-Range", "bytes 0-6/20") + .expectBody(String::class.java) + .isEqualTo("""{"a":1}""") + } + + @Test + fun `이미 공통 형식인 응답은 그대로 둔다`() { + get("/wrapped") + .expectBody() + .json("""{"status":201,"code":"CREATED","message":"생성됨","data":{"id":9}}""", true) + } + + @Test + fun `JSON이 아닌 응답은 그대로 둔다`() { + get("/text") + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String::class.java) + .isEqualTo("plain") + } + + @Test + fun `최대 크기를 넘는 chunked 응답은 원문 그대로 보낸다`() { + val body = get("/large").expectBody(String::class.java).returnResult().responseBody!! + + assertThat(body).isEqualTo(largeJson) + } + + companion object { + private val largeJson = """{"items":"${"a".repeat(MAX_BYTES * 2)}"}""" + + private val server = + HttpServer.create(InetSocketAddress("localhost", 0), 0).apply { + createContext("/") { exchange -> + val (status, type, body) = + when (exchange.requestURI.path.removePrefix("/user")) { + "/item" -> Triple(200, "application/json", """{"id":1,"name":"사과"}""") + "/list" -> Triple(200, "application/json", "[1,2,3]") + "/missing" -> Triple(404, "application/json", """{"code":"ITEM_NOT_FOUND","message":"상품이 없습니다."}""") + "/wrapped" -> + Triple( + 201, + "application/json", + """{"status":201,"code":"CREATED","message":"생성됨","data":{"id":9}}""", + ) + "/text" -> Triple(200, "text/plain", "plain") + "/decimal" -> Triple(200, "application/json", """{"amount":1234567890.123456789,"price":1.50}""") + "/partial" -> Triple(206, "application/json", """{"a":1}""") + "/large" -> Triple(200, "application/json", largeJson) + else -> Triple(404, "text/plain", "") + } + val bytes = body.toByteArray() + exchange.responseHeaders.set("Content-Type", type) + exchange.responseHeaders.set("ETag", "\"v1\"") + if (exchange.requestURI.path.endsWith("/partial")) exchange.responseHeaders.set("Content-Range", "bytes 0-6/20") + // /large는 길이를 모르는 chunked 응답으로 보냅니다. + val length = if (exchange.requestURI.path.endsWith("/large")) 0L else bytes.size.toLong() + exchange.sendResponseHeaders(status, length) + exchange.responseBody.use { it.write(bytes) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun routes(registry: DynamicPropertyRegistry) { + registry.add("USER_URL") { "http://localhost:${server.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopServer() { + server.stop(0) + } + + private fun token(): String = + Jwts + .builder() + .subject("1") + .signWith(Keys.hmacShaKeyFor(SECRET.toByteArray())) + .compact() + } +} diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicyTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicyTest.kt new file mode 100644 index 0000000..d680898 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/response/ApiResponseWrappingPolicyTest.kt @@ -0,0 +1,87 @@ +package com.rush.gateway.response + +import com.rush.gateway.response.ApiResponseWrappingPolicy.Decision +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.util.unit.DataSize + +class ApiResponseWrappingPolicyTest { + private val policy = ApiResponseWrappingPolicy(ApiResponseWrapperProperties(DataSize.ofKilobytes(1), listOf("/shop/files/**"))) + + private fun json(length: Long = -1) = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + if (length >= 0) contentLength = length + } + + private fun decide( + path: String = "/shop/items", + method: HttpMethod = HttpMethod.GET, + requestHeaders: HttpHeaders = HttpHeaders(), + status: HttpStatus = HttpStatus.OK, + responseHeaders: HttpHeaders = json(), + ) = policy.decide(path, method, requestHeaders, status, responseHeaders) + + @Test + fun `JSON 응답은 감싼다`() { + assertEquals(Decision.WRAP, decide()) + assertEquals( + Decision.WRAP, + decide( + responseHeaders = + HttpHeaders().apply { + contentType = + MediaType.parseMediaType("application/problem+json") + }, + ), + ) + } + + @Test + fun `제외 경로는 감싸지 않는다`() { + assertEquals(Decision.SKIP_EXCLUDED_PATH, decide(path = "/actuator/health")) + assertEquals(Decision.SKIP_EXCLUDED_PATH, decide(path = "/shop/v3/api-docs")) + assertEquals(Decision.SKIP_EXCLUDED_PATH, decide(path = "/fallback")) + assertEquals(Decision.SKIP_EXCLUDED_PATH, decide(path = "/shop/files/1")) + } + + @Test + fun `WebSocket 업그레이드는 감싸지 않는다`() { + assertEquals(Decision.SKIP_WEBSOCKET, decide(requestHeaders = HttpHeaders().apply { upgrade = "websocket" })) + } + + @Test + fun `본문이 없는 응답은 감싸지 않는다`() { + assertEquals(Decision.SKIP_NO_BODY, decide(method = HttpMethod.HEAD)) + assertEquals(Decision.SKIP_NO_BODY, decide(status = HttpStatus.NO_CONTENT)) + assertEquals(Decision.SKIP_NO_BODY, decide(status = HttpStatus.NOT_MODIFIED)) + } + + @Test + fun `부분 응답은 감싸지 않는다`() { + assertEquals(Decision.SKIP_PARTIAL, decide(status = HttpStatus.PARTIAL_CONTENT)) + assertEquals(Decision.SKIP_PARTIAL, decide(responseHeaders = json().apply { set(HttpHeaders.CONTENT_RANGE, "bytes 0-6/20") })) + } + + @Test + fun `압축된 응답은 감싸지 않는다`() { + assertEquals(Decision.SKIP_ENCODED, decide(responseHeaders = json().apply { set(HttpHeaders.CONTENT_ENCODING, "gzip") })) + assertEquals(Decision.WRAP, decide(responseHeaders = json().apply { set(HttpHeaders.CONTENT_ENCODING, "identity") })) + } + + @Test + fun `JSON이 아니면 감싸지 않는다`() { + assertEquals(Decision.SKIP_NOT_JSON, decide(responseHeaders = HttpHeaders().apply { contentType = MediaType.TEXT_PLAIN })) + assertEquals(Decision.SKIP_NOT_JSON, decide(responseHeaders = HttpHeaders())) + } + + @Test + fun `Content-Length가 최대 크기를 넘으면 감싸지 않는다`() { + assertEquals(Decision.WRAP, decide(responseHeaders = json(1024))) + assertEquals(Decision.SKIP_TOO_LARGE, decide(responseHeaders = json(1025))) + } +}