Spring Cloud Gateway: Post Filter Web Client Request - kotlin

We are using Spring Cloud Gateway in order to route requests to multiple underlying services. The calls to these underlying services will be sequential and potentially feed into one another (response from one being used in the request for the next). We have a working solution for when we need to make those requests sequentially BEFORE the main request, but after the main request we are having problems with feeding the response of one proxy request into the request of the next.
The way we have planned on feeding the response from one request to the next is by making the request using a WebClient in the GatewayFilter and storing the response string in the exchange's attribute store. Then during the next proxy request we supply an attribute name to optionally pull the request body from. This works well when using "pre" filters, because the first proxy request is built, executed and response cached before the second request is built and executed, so the chain of attributes works as expected. The problem comes when working with "post" filters. In the post proxy, the web client requests are all built before the subsequent request has finished. So the attribute store never has the response from the previous request, meaning the next request doesn't work as intended because it doesn't have a valid request body.
My understanding was that calling chain.filter(exchange).then(Mono.fromRunnable{ ... }) would cause the .then logic to execute only after the prior filters had fully completed. This does not seem to be the case. In other filter types like logging, response manipulation, etc the post filters execute in the correct order, but when creating a WebClient they don't seem to.
Does anyone have any ideas on how this desired behavior might be achievable?
Pre-Proxy Filter Code(Working):
class PreProxyGatewayFilterFactory: AbstractGatewayFilterFactory<PreProxyGatewayFilterFactory.Params>(Params::class.java) {
override fun apply(params: Params): GatewayFilter {
return OrderedGatewayFilter(
{ exchange, chain ->
ServerWebExchangeUtils.cacheRequestBody(exchange){
val cachedExchange = exchange.mutate().request(it).build()
executeRequest(cachedExchange, params)
.map { response ->
val body = response.body.toString()
cacheResponse(
response.body.toString(),
params.cachedResponseBodyAttributeName,
cachedExchange
)
}
.flatMap(chain::filter)
}
}, params.order)
}
private fun cacheResponse(response: String, attributeName: String?, exchange: ServerWebExchange): ServerWebExchange{
if(!attributeName.isNullOrBlank()){
exchange.attributes[attributeName] = response
}
return exchange
}
private fun executeRequest(exchange: ServerWebExchange, params: Params): Mono<ResponseEntity<String>>{
val request = when(exchange.request.method){
HttpMethod.PUT -> WebClient.create().put().uri(params.proxyPath).body(createProxyRequestBody(exchange, params.cachedRequestBodyAttributeName))
HttpMethod.POST -> WebClient.create().post().uri(params.proxyPath).body(createProxyRequestBody(exchange, params.cachedRequestBodyAttributeName))
HttpMethod.GET -> WebClient.create().get().uri(params.proxyPath)
HttpMethod.DELETE -> WebClient.create().delete().uri(params.proxyPath)
else -> throw Exception("Invalid request method passed in to the proxy filter")
}
return request.headers { headers ->
headers.addAll(exchange.request.headers)
headers.remove(CONTENT_LENGTH)
}
.exchange()
.flatMap{ response ->
response.toEntity(String::class.java)
}
}
private fun createProxyRequestBody(exchange: ServerWebExchange, attributeName: String?): BodyInserter<out Flux<out Any>, ReactiveHttpOutputMessage> {
val cachedBody = attributeName?.let { attrName ->
exchange.getAttributeOrDefault<String>(attrName, "null")
} ?: "null"
return if(cachedBody != "null"){
BodyInserters.fromPublisher(Flux.just(cachedBody), String::class.java)
} else {
BodyInserters.fromDataBuffers(exchange.request.body)
}
}
data class Params(
val proxyPath: String = "",
val cachedRequestBodyAttributeName: String? = null,
val cachedResponseBodyAttributeName: String? = null,
val order: Int = 0
)
}
Post-Proxy Filter Code (Not Working)
class PostProxyGatewayFilterFactory: AbstractGatewayFilterFactory<PostProxyGatewayFilterFactory.Params>(Params::class.java) {
override fun apply(params: Params): GatewayFilter {
return OrderedGatewayFilter(
{ exchange, chain ->
ServerWebExchangeUtils.cacheRequestBody(exchange){
val cachedExchange = exchange.mutate().request(it).build()
//Currently using a cached body does not work in post proxy
chain.filter(cachedExchange).then( Mono.fromRunnable{
executeRequest(cachedExchange, params)
.map { response ->
cacheResponse(
response.body.toString(),
params.cachedResponseBodyAttributeName,
cachedExchange
)
}
.flatMap {
Mono.empty<Void>()
}
})
}
}, params.order)
}
private fun cacheResponse(response: String, attributeName: String?, exchange: ServerWebExchange): ServerWebExchange{
if(!attributeName.isNullOrBlank()){
exchange.attributes[attributeName] = response
}
return exchange
}
private fun executeRequest(exchange: ServerWebExchange, params: Params): Mono<ResponseEntity<String>>{
val request = when(exchange.request.method){
HttpMethod.PUT -> WebClient.create().put().uri(params.proxyPath).body(createProxyRequestBody(exchange, params.cachedRequestBodyAttributeName))
HttpMethod.POST -> WebClient.create().post().uri(params.proxyPath).body(createProxyRequestBody(exchange, params.cachedRequestBodyAttributeName))
HttpMethod.GET -> WebClient.create().get().uri(params.proxyPath)
HttpMethod.DELETE -> WebClient.create().delete().uri(params.proxyPath)
else -> throw Exception("Invalid request method passed in to the proxy filter")
}
return request.headers { headers ->
headers.addAll(exchange.request.headers)
headers.remove(CONTENT_LENGTH)
}
.exchange()
.flatMap{ response ->
response.toEntity(String::class.java)
}
}
private fun createProxyRequestBody(exchange: ServerWebExchange, attributeName: String?): BodyInserter<out Flux<out Any>, ReactiveHttpOutputMessage> {
val cachedBody = attributeName?.let { attrName ->
exchange.getAttributeOrDefault<String>(attrName, "null")
} ?: "null"
return if(cachedBody != "null"){
BodyInserters.fromPublisher(Flux.just(cachedBody), String::class.java)
} else {
BodyInserters.fromDataBuffers(exchange.request.body)
}
}
data class Params(
val proxyPath: String = "",
val cachedRequestBodyAttributeName: String? = null,
val cachedResponseBodyAttributeName: String? = null,
val order: Int = 0
)
}

Was finally able to get to a working solution for the post filter proxy pulling it's request body from the attributes. It was a relatively straightforward fix that I just couldn't find the answer to. Instead of using chain.filter(exchange).then(Mono.fromRunnable { ...execute proxy request...}) I just needed to use chain.filter(exchange).then(Mono.defer { ...execute proxy request...}).

Related

How to reuse cached value in ProjectReactor

I would like my server to call the login endpoint of another server and cache the auth token for later use. My issue is that when the server tries to reuse the existing token it hangs indefinitely or infinite loops.
#Component
class ApiWebClient {
private var authToken = Mono.just(AuthToken("", Instant.ofEpochSecond(0)))
fun login(): Mono<AuthToken> {
authToken = doesTokenNeedRefreshing().flatMap { needsRefreshing ->
if (needsRefreshing) {
WebClient.create().post()
.uri("https://example.com/login")
.body(
Mono.just("Credentials"),
String::class.java
).exchangeToMono { response ->
response.bodyToMono<LoginResponse>()
}.map { response ->
LOGGER.info("Successfully logged in")
AuthToken(response.token, Instant.now())
}
} else {
LOGGER.info("Reuse token")
authToken
}
}.cache()
return authToken
}
private fun doesTokenNeedRefreshing(): Mono<Boolean> {
return authToken.map {
Instant.now().minusMillis(ONE_MINUTE_IN_MILLIS).isAfter(it.lastModified)
}
}
class AuthToken(
var token: String,
var lastModified: Instant
)
companion object {
private const val ONE_MINUTE_IN_MILLIS = 60 * 1000L
#Suppress("JAVA_CLASS_ON_COMPANION")
#JvmStatic
private val LOGGER = LoggerFactory.getLogger(javaClass.enclosingClass)
}
}
If login gets called twice within the ONE_MINUTE_IN_MILLIS amount of time then it just hangs. I suspect this is because the doesTokenNeedRefreshing() calls a .map {} on authToken and then later down the chain authToken is reassigned to itself. As well, there's an attempt to recache that exact same value. I've played around with recreating AuthToken each time instead of returning the same instance but no luck. The server either hangs or infinite loops.
How can I achieve returning the same instance of the cached value so I don't have to make a web request each time?
The best way would be to use ServerOAuth2AuthorizedClientExchangeFilterFunction from Spring Security that you could customize to satisfy your needs. In this case token will be updated automatically behind the scene.
If you are looking for "manual" approach, you just need to combine 2 requests.
public Mono<String> getResourceWithToken() {
return getToken()
.flatMap(token -> getResource(token));
}
private Mono<String> getResource(String authToken) {
return client.get()
.uri("/resource")
.headers(h -> h.setBearerAuth(authToken))
.retrieve()
.bodyToMono(String.class);
}
private Mono<String> getToken() {
return client.post()
.uri("/oauth/token")
.header("Authorization", "Basic " + {CREDS})
.body(BodyInserters.fromFormData("grant_type", "client_credentials"))
.retrieve()
.bodyToMono(JsonNode.class)
.map(tokenResponse ->
tokenResponse.get("access_token").textValue()
);
}
In this case you will always execute 2 requests and get token for every resource request. Usually token has expiration and you could save some requests by caching getToken() Mono.
private Mono<String> tokenRequest = getToken().cache(Duration.ofMinutes(30));
public Mono<String> getResourceWithToken() {
return tokenRequest
.flatMap(token -> getResource(token));
}
What I was looking for was a switchIfEmpty statement to return the existing cached value if it doesn't need to be refreshed.
#Component
class ApiWebClient {
private var authToken = Mono.just(AuthToken("", Instant.ofEpochSecond(0)))
fun login(): Mono<AuthToken> {
authToken = doesTokenNeedRefreshing().flatMap { needsRefreshing ->
if (needsRefreshing) {
WebClient.create().post()
.uri("https://example.com/login")
.body(
Mono.just("Credentials"),
String::class.java
).exchangeToMono { response ->
response.bodyToMono<LoginResponse>()
}.map { response ->
LOGGER.info("Successfully logged in")
AuthToken(response.token, Instant.now())
}
} else {
LOGGER.info("Reuse token")
Mono.empty()
}
}.cache()
.switchIfEmpty(authToken)
return authToken
}
}
In this example, if the token doesn't need to be refreshed then the stream returns an empty Mono. Then the switchIfEmpty statement returns the original auth token. This, therefore, avoids "recursively" returning the same stream over and over again.

How to get http body of call.reponse in Ktor?

I build a web server with Ktor, and want to cache API method result. But I don't know how to get the response body from call.response. Code like below
fun Application.module(){
// before method was called
intercept(ApplicationCallPipeline.Features) {
val cache = redis.get("cache")
if(cache) {
call.respond(cache)
}
return #intercept finish()
}
// after method was called
intercept(ApplicationCallPipeline.Fallback) {
// TODO, how to get call.response.body
redis.set("cache", call.response.body)
}
}
If I can't get the response body, Any other solution to cache the result of an API method in Ktor?
Inside an interceptor, for the ApplicationCallPipeline.Features phase you can add a phase just before the ApplicationSendPipeline.Engine and intercept it to retreive a response body (content):
val phase = PipelinePhase("phase")
call.response.pipeline.insertPhaseBefore(ApplicationSendPipeline.Engine, phase)
call.response.pipeline.intercept(phase) { response ->
val content: ByteReadChannel = when (response) {
is OutgoingContent.ByteArrayContent -> ByteReadChannel(response.bytes())
is OutgoingContent.NoContent -> ByteReadChannel.Empty
is OutgoingContent.ReadChannelContent -> response.readFrom()
is OutgoingContent.WriteChannelContent -> GlobalScope.writer(coroutineContext, autoFlush = true) {
response.writeTo(channel)
}.channel
else -> error("")
}
// Do something with content
}

Ktor Server/Application request/response body logging

Is there any way to log the request and response body from the ktor server communication?
The buildin CallLogging feature only logs the metadata of a call. I tried writing my own logging feature like in this example: https://github.com/Koriit/ktor-logging/blob/master/src/main/kotlin/korrit/kotlin/ktor/features/logging/Logging.kt
class Logging(private val logger: Logger) {
class Configuration {
var logger: Logger = LoggerFactory.getLogger(Logging::class.java)
}
private suspend fun logRequest(call: ApplicationCall) {
logger.info(StringBuilder().apply {
appendLine("Received request:")
val requestURI = call.request.path()
appendLine(call.request.origin.run { "${method.value} $scheme://$host:$port$requestURI $version" })
call.request.headers.forEach { header, values ->
appendLine("$header: ${values.firstOrNull()}")
}
try {
appendLine()
appendLine(String(call.receive<ByteArray>()))
} catch (e: RequestAlreadyConsumedException) {
logger.error("Logging payloads requires DoubleReceive feature to be installed with receiveEntireContent=true", e)
}
}.toString())
}
private suspend fun logResponse(call: ApplicationCall, subject: Any) {
logger.info(StringBuilder().apply {
appendLine("Sent response:")
appendLine("${call.request.httpVersion} ${call.response.status()}")
call.response.headers.allValues().forEach { header, values ->
appendLine("$header: ${values.firstOrNull()}")
}
when (subject) {
is TextContent -> appendLine(subject.text)
is OutputStreamContent -> appendLine() // ToDo: How to get response body??
else -> appendLine("unknown body type")
}
}.toString())
}
/**
* Feature installation.
*/
fun install(pipeline: Application) {
pipeline.intercept(ApplicationCallPipeline.Monitoring) {
logRequest(call)
proceedWith(subject)
}
pipeline.sendPipeline.addPhase(responseLoggingPhase)
pipeline.sendPipeline.intercept(responseLoggingPhase) {
logResponse(call, subject)
}
}
companion object Feature : ApplicationFeature<Application, Configuration, Logging> {
override val key = AttributeKey<Logging>("Logging Feature")
val responseLoggingPhase = PipelinePhase("ResponseLogging")
override fun install(pipeline: Application, configure: Configuration.() -> Unit): Logging {
val configuration = Configuration().apply(configure)
return Logging(configuration.logger).apply { install(pipeline) }
}
}
}
It works fine for logging the request body using the DoubleReceive plugin. And if the response is plain text i can log the response as the subject in the sendPipeline interception will be of type TextContent or like in the example ByteArrayContent.
But in my case i am responding a data class instance with Jackson ContentNegotiation. In this case the subject is of type OutputStreamContent and i see no options to geht the serialized body from it.
Any idea how to log the serialized response json in my logging feature? Or maybe there is another option using the ktor server? I mean i could serialize my object manually and respond plain text, but thats an ugly way to do it.
I'm not shure about if this is the best way to do it, but here it is:
public fun ApplicationResponse.toLogString(subject: Any): String = when(subject) {
is TextContent -> subject.text
is OutputStreamContent -> {
val channel = ByteChannel(true)
runBlocking {
(subject as OutputStreamContent).writeTo(channel)
val buffer = StringBuilder()
while (!channel.isClosedForRead) {
channel.readUTF8LineTo(buffer)
}
buffer.toString()
}
}
else -> String()
}

Spring Cloud Custom GatewayFilter - Modify Response POST filter with results from another client request inside filter

I have a POST Gateway filter that I want to modify the response body with the response of a separate webclient request within the gateway filter. I am able to get as far as sending the WebClient().create().post().exchange() within my custom filter. I can see this in my logs
onStateChange(POST{uri=/test, connection=PooledConnection{channel=[id: 0x38eb2b4f, L:/127.0.0.1:51643 - R:localhost/127.0.0.1:9000]}}, [request_prepared])
and
onStateChange(POST{uri=/test, connection=PooledConnection{channel=[id: 0x38eb2b4f, L:/127.0.0.1:51643 - R:localhost/127.0.0.1:9000]}}, [request_sent])
the connection hangs here and doesn't complete.
here is my custom filter code
class PostProxyGatewayFilterFactory : AbstractGatewayFilterFactory<PostProxyGatewayFilterFactory.Params>(Params::class.java) {
override fun apply(params: Params): GatewayFilter {
val cachedBody = StringBuilder()
return GatewayFilter { exchange: ServerWebExchange, chain: GatewayFilterChain ->
chain.filter(exchange).then(
executeRequest(cachedBody,exchange, params)
.map {
val mr = ResponseHandler(exchange)
mr.mutateResponse(it.body.toString())
}.flatMap{
it
}
) }
}
data class Params(
val urlPath: String = "",
)
private fun cache(cachedBody: StringBuilder, buffer: DataBuffer) {
cachedBody.append(Charsets.UTF_8.decode(buffer.asByteBuffer())
.toString())
}
private fun executeRequest(cachedBody: StringBuilder, exchange: ServerWebExchange, params: PostProxyGatewayFilterFactory.Params): Mono<ResponseEntity<JsonNode>>{
val request = when(exchange.request.method){
HttpMethod.PUT -> WebClient.create().put().uri(params.urlPath).body(BodyInserters.fromDataBuffers(exchange.request.body.doOnNext{ cache(cachedBody, it)}))
HttpMethod.POST -> WebClient.create().post().uri(params.urlPath).body(BodyInserters.fromDataBuffers(exchange.request.body.doOnNext{ cache(cachedBody, it)}))
HttpMethod.GET -> WebClient.create().get().uri(params.urlPath)
HttpMethod.DELETE -> WebClient.create().delete().uri(params.urlPath)
else -> throw Exception("Invalid request method passed in to the proxy filter")
}
return request.headers { it.addAll(exchange.request.headers) }
.exchange()
.flatMap{
it.toEntity(JsonNode::class.java)
}
}
}
here is my ResponseHandler class
class ResponseHandler(val delegate: ServerWebExchange) {
fun mutateResponse(body: String): Mono<Void> {
val bytes: ByteArray = body.toByteArray(StandardCharsets.UTF_8)
val buffer: DataBuffer = delegate.response.bufferFactory().wrap(bytes)
return delegate.response.writeWith(Flux.just(buffer))
}
}
here is application.yml
- id: proxy
uri: http://${HOST:localhost}:${PORT:9000}
predicates:
- Path=/proxy/**
filters:
- RewritePath=/test(?<segment>/?.*), $\{segment}
- name: PostProxy
args:
proxyBasePath: http://localhost:9000/thisSecond
So the idea is to send request to localhost:9001/test/thisFirst (proxied to localhost:9000/thisFirst which does happen successfully),get this response back and do nothing with this response, make WebClient request to localhost:9000/thisSecond via executeRequest() ,return this response, and then use that response as the new exchange.response body. I am also not sure if ResponseHandler is correct as executeRequest() never finishes. This would be part 2 of the question once I can resolve why executeRequest() never finishes.

How to build a proxy for HTTP requests with Fuel

I was using restTemplate and this was my method:
fun fetchAvailableCars(): Aggregations? {
val availableCarsUrl = UriComponentsBuilder
.fromHttpUrl(getCatalogUrl())
.query("aggsBy={aggregators}")
.buildAndExpand("brand,model")
.toString()
return restTemplate.getForEntity(availableCarsUrl, Aggregations::class.java).body
}
I'm trying to use Fuel to do basically the same thing (but handling errors), but I couldn't find a simple way to do that.
This is what I have so far:
fun fetchAvailableCarsWithFuel() {
val availableCarsUrl = UriComponentsBuilder
.fromHttpUrl(getCatalogUrl())
.query("aggsBy={aggregators}")
.buildAndExpand("brand,model")
.toString()
Fuel.get(availableCarsUrl)
.responseObject<Aggregations> { _, _, result ->
when (result) {
is Success -> {
result.get()
}
is Failure -> {
// log.error
}
}
}
}
but there's no easy way to return the body from inside the lambda. What are the common ways to do that?
P.S.: I'm using fuel-jackson to deserialize the response