Accessing the POST request body in Spring WebClient filters

Accessing the POST request body in Spring WebClient filters

ยท

3 min read

Accessing the POST request body in Spring WebClient filters can be a bit tricky, as the request body is typically passed as a stream. In this blog post, we'll explore two solutions to this problem.

The problem

In some cases, you may need to access the POST request body in a Spring WebClient filter, in order to perform an operation such as signing the request with JWS header. However, because the request body is passed as a stream, it can be difficult to access and manipulate all at once.

Solution 1

Extending the ClientHttpRequestDecorator class

One approach to solving this problem is to extend the ClientHttpRequestDecorator class and override the writeWith method. This allows you to gain access to the DataBuffer publisher, which represents the stream of the request body. You can then concatenate and retrieve the original request by joining the published data buffers using DataBufferUtils.

class BufferingRequestDecorator(delegate: ClientHttpRequest) : ClientHttpRequestDecorator(delegate) {

    override fun writeWith(body: Publisher<out DataBuffer>): Mono<Void> =
        DataBufferUtils.join(body)
            .flatMap { db -> setJwsHeader(extractBytes(db)).then(super.writeWith(Mono.just(db))) }

    private fun setJwsHeader(data: ByteArray = byteArrayOf()): Mono<Void> {
        headers.add("JWS-header", createJws(data)) // or do whatever you want with the data
        return Mono.empty()
    }

    private fun extractBytes(data: DataBuffer): ByteArray {
        val bytes = ByteArray(data.readableByteCount())
        data.read(bytes)
        data.readPosition(0)
        return bytes
    }
}

Once you have this class implemented, you can call it in the WebClient filter and deconstruct the old request and create a new one out of it.

class WebClientBufferingFilter : ExchangeFilterFunction {
    override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> =
        next.exchange(
            ClientRequest.from(request)
                .body { outputMessage, context ->
                    request.body().insert(
                        BufferingRequestDecorator(outputMessage),
                        context
                    )
                }.build()
        )
}

Create a webclient with this filter

WebClient.builder()
        .filter(WebClientBufferingFilter())
        .build()

Solution 2

Using a custom JSON Encoder

By default, WebClient uses Jackson to encode the data into JSON. When setting up the request, we use the utility BodyInserters.fromValue to create a body inserter for our data. The DefaultRequestBuilder for WebClient keeps this BodyInserter object at the time when the request will be sent, passing it all known message-writers and other relevant contextual information. However, we may need to access the request body before it is sent in order to perform additional actions such as signing or adding an authorization header.

Create a wrapper class around Jackson2JsonEncoder that allows us to intercept the encoded body. Specifically, we will be wrapping the encode method implementation from AbstractJackson2Encoder.

const val REQUEST_CONTEXT_KEY = "test"
class BodyProvidingJsonEncoder : Jackson2JsonEncoder() {
    override fun encode(
        inputStream: Publisher<out Any>,
        bufferFactory: DataBufferFactory,
        elementType: ResolvableType,
        mimeType: MimeType?,
        hints: MutableMap<String, Any>?
    ): Flux<DataBuffer> {
        return super.encode(inputStream, bufferFactory, elementType, mimeType, hints)
            .flatMap { db: DataBuffer ->
                Mono.deferContextual {
                    val clientHttpRequest = it.get<ClientHttpRequest>(REQUEST_CONTEXT_KEY)
                    db
                }
            }
    }

    private fun extractBytes(data: DataBuffer): ByteArray {
        val bytes = ByteArray(data.readableByteCount())
        data.read(bytes)
        data.readPosition(0)
        return bytes
    }
}

Build a custom ReactorClientHttpConnector to put the request in the context.

class MessageSigningHttpConnector : ReactorClientHttpConnector() {
    override fun connect(
        method: HttpMethod,
        uri: URI,
        requestCallback: Function<in ClientHttpRequest, Mono<Void>>
    ): Mono<ClientHttpResponse> {
        // execute the super-class method as usual, but insert an interception into the requestCallback that can
        // capture the request to be saved for this thread.
        return super.connect(
            method, uri
        ) { incomingRequest: ClientHttpRequest ->
            requestCallback.apply(incomingRequest).contextWrite {
                it.put(
                    REQUEST_CONTEXT_KEY,
                    incomingRequest
                )
            }
        }
    }
}

Define a custom ExchangeFunction using the utility ExchangeFunctions.create() which accepts a custom HttpConnector. This connector has access to the function that makes the request. It is at this point that we can get a handle on the ClientHttpRequest and wait for the body to be serialized so that the header can be added.

val httpConnector = MessageSigningHttpConnector()
    val bodyProvidingJsonEncoder = BodyProvidingJsonEncoder()

    val client = WebClient.builder()
        .exchangeFunction(ExchangeFunctions.create(
            httpConnector,
            ExchangeStrategies
                .builder()
                .codecs { clientDefaultCodecsConfigurer: ClientCodecConfigurer ->
                    clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonEncoder(bodyProvidingJsonEncoder)
                    clientDefaultCodecsConfigurer.defaultCodecs()
                        .jackson2JsonDecoder(Jackson2JsonDecoder(ObjectMapper(), MediaType.APPLICATION_JSON))
                }
                .build()
        ))
        .build()

As always, be sure to test your codes thoroughly.

Solution 2 is originally found in https://andrew-flower.com/blog/Custom-HMAC-Auth-with-Spring-WebClient. I tinkered with it a bit before finding solution 1 which is somehow inspired by github.com/spring-projects/spring-framework... Finally, I decided to go with solution 1 as I found it to be a bit more simple and elegant.

Did you find this article valuable?

Support Driptaroop Das by becoming a sponsor. Any amount is appreciated!

ย