Skip to content

Commit a1ffc16

Browse files
committed
feat: enhance header management in dynamic proxy
- Add support for custom request header removal and mapping in `DynamicProxyGlobalFilter`. - Extend `DynamicProxyProperties` to include `removedRequestHeaders` and `requestHeaderMappings`. - Implement logic to apply header mappings and removal in `withDownstreamHeaders`. - Update integration tests to verify header handling functionality. - Add unit tests for `DynamicProxyProperties` binding.
1 parent 4b0717d commit a1ffc16

4 files changed

Lines changed: 87 additions & 5 deletions

File tree

autoconfigure/src/main/kotlin/org/openprojectx/spring/cloud/proxy/autoconfigure/DynamicProxyGlobalFilter.kt

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,45 @@ internal class DynamicProxyGlobalFilter(
1919
val prefix = properties.normalizedPrefix()
2020
if (path != prefix && !path.startsWith("$prefix/")) return chain.filter(exchange)
2121

22-
val destination = try {
23-
destination(path, exchange.request.uri.rawQuery)
22+
val destination: URI
23+
val downstreamExchange: ServerWebExchange
24+
try {
25+
destination = destination(path, exchange.request.uri.rawQuery)
26+
downstreamExchange = withDownstreamHeaders(exchange)
2427
} catch (exception: IllegalArgumentException) {
2528
return badRequest(exchange, exception.message ?: "Invalid dynamic proxy destination")
2629
}
2730

28-
exchange.attributes[GATEWAY_REQUEST_URL_ATTR] = destination
29-
return chain.filter(exchange)
31+
downstreamExchange.attributes[GATEWAY_REQUEST_URL_ATTR] = destination
32+
return chain.filter(downstreamExchange)
33+
}
34+
35+
private fun withDownstreamHeaders(exchange: ServerWebExchange): ServerWebExchange {
36+
val mappings = properties.requestHeaderMappings.onEach { mapping ->
37+
require(mapping.source.isNotBlank() && mapping.target.isNotBlank()) {
38+
"Request header mapping source and target must not be blank"
39+
}
40+
}
41+
42+
val request = exchange.request.mutate().headers { headers ->
43+
// Capture mapped values first so a source can also appear in removedRequestHeaders.
44+
val mappedValues = mappings.map { mapping -> mapping to headers.getValuesAsList(mapping.source).toList() }
45+
46+
properties.removedRequestHeaders.forEach { header ->
47+
require(header.isNotBlank()) { "Removed request header names must not be blank" }
48+
headers.remove(header)
49+
}
50+
51+
mappedValues.forEach { (mapping, values) ->
52+
headers.remove(mapping.source)
53+
if (values.isNotEmpty()) {
54+
headers.remove(mapping.target)
55+
headers.addAll(mapping.target, values)
56+
}
57+
}
58+
}.build()
59+
60+
return exchange.mutate().request(request).build()
3061
}
3162

3263
private fun destination(path: String, rawQuery: String?): URI {

autoconfigure/src/main/kotlin/org/openprojectx/spring/cloud/proxy/autoconfigure/DynamicProxyProperties.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,15 @@ data class DynamicProxyProperties(
1010
var pathPrefix: String = "/dynamic-proxy",
1111
/** URI schemes that callers may select. */
1212
var allowedSchemes: Set<String> = setOf("http", "https"),
13+
/** Request headers that must never be forwarded to the dynamic destination. */
14+
var removedRequestHeaders: Set<String> = setOf("Authorization", "Cookie"),
15+
/** Headers that carry destination-specific values under a different public name. */
16+
var requestHeaderMappings: List<RequestHeaderMapping> = listOf(
17+
RequestHeaderMapping("X-Target-Authorization", "Authorization"),
18+
),
19+
)
20+
21+
data class RequestHeaderMapping(
22+
var source: String = "",
23+
var target: String = "",
1324
)

autoconfigure/src/test/kotlin/org/openprojectx/spring/cloud/proxy/autoconfigure/DynamicProxyIntegrationTest.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import org.springframework.boot.SpringBootConfiguration
77
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
88
import org.springframework.boot.test.context.SpringBootTest
99
import org.springframework.boot.test.web.server.LocalServerPort
10+
import org.springframework.http.HttpHeaders
1011
import org.springframework.http.MediaType
1112
import org.springframework.test.web.reactive.server.WebTestClient
1213
import org.testcontainers.containers.GenericContainer
@@ -36,7 +37,12 @@ class DynamicProxyIntegrationTest {
3637
"method": "POST",
3738
"urlPath": "/target%20host-api/nested",
3839
"queryParameters": { "q": { "equalTo": "spring cloud" } },
39-
"headers": { "X-Proxy-Test": { "equalTo": "forwarded" } },
40+
"headers": {
41+
"X-Proxy-Test": { "equalTo": "forwarded" },
42+
"Authorization": { "equalTo": "Bearer target-credential" },
43+
"Cookie": { "absent": true },
44+
"X-Target-Authorization": { "absent": true }
45+
},
4046
"bodyPatterns": [{ "equalToJson": { "hello": "gateway" } }]
4147
},
4248
"response": {
@@ -66,6 +72,9 @@ class DynamicProxyIntegrationTest {
6672
.build()
6773
}
6874
.header("X-Proxy-Test", "forwarded")
75+
.header(HttpHeaders.AUTHORIZATION, "Bearer proxy-credential")
76+
.header(HttpHeaders.COOKIE, "proxy-session=secret")
77+
.header("X-Target-Authorization", "Bearer target-credential")
6978
.contentType(MediaType.APPLICATION_JSON)
7079
.bodyValue("""{"hello":"gateway"}""")
7180
.exchange()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.openprojectx.spring.cloud.proxy.autoconfigure
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.junit.jupiter.api.Test
5+
import org.springframework.boot.context.properties.bind.Bindable
6+
import org.springframework.boot.context.properties.bind.Binder
7+
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource
8+
9+
class DynamicProxyPropertiesTest {
10+
11+
@Test
12+
fun `binds custom removed headers and header mappings`() {
13+
val source = MapConfigurationPropertySource(
14+
mapOf(
15+
"spring.cloud.proxy.dynamic.removed-request-headers[0]" to "X-Internal-Token",
16+
"spring.cloud.proxy.dynamic.removed-request-headers[1]" to "X-User-Session",
17+
"spring.cloud.proxy.dynamic.request-header-mappings[0].source" to "X-Target-Token",
18+
"spring.cloud.proxy.dynamic.request-header-mappings[0].target" to "X-Api-Token",
19+
),
20+
)
21+
22+
val properties = Binder(source)
23+
.bind("spring.cloud.proxy.dynamic", Bindable.of(DynamicProxyProperties::class.java))
24+
.get()
25+
26+
assertThat(properties.removedRequestHeaders)
27+
.containsExactly("X-Internal-Token", "X-User-Session")
28+
assertThat(properties.requestHeaderMappings)
29+
.containsExactly(RequestHeaderMapping("X-Target-Token", "X-Api-Token"))
30+
}
31+
}

0 commit comments

Comments
 (0)