Spring WebClient and Java Date-Time Fields
WebClient is the Spring Framework's reactive client for making service-to-service calls. WebClient has become a go-to utility for me; however, I unexpectedly encountered an issue recently in the way it handles Java 8 time fields that tripped me up. This post will go into the details of the date and time fields in Java.
You may also like: Java 8 Date and Time
Happy Path
First, the happy path. When using a WebClient
, Spring Boot advises a WebClient.Builder
to be injected into a class instead of the WebClient
itself and a WebClient.Builder
is already auto-configured and available for injection.
Consider a fictitious "City" domain and a client to create a "City". "City" has a simple structure — note that the creationDate
is a Java 8 "Instant" type:
xxxxxxxxxx
import java.time.Instant
data class City(
val id: Long,
val name: String,
val country: String,
val pop: Long,
val creationDate: Instant = Instant.now()
)
The client to create an instance of this type looks like this:
class CitiesClient(
private val webClientBuilder: WebClient.Builder,
private val citiesBaseUrl: String
) {
fun createCity(city: City): Mono<City> {
val uri: URI = UriComponentsBuilder
.fromUriString(citiesBaseUrl)
.path("/cities")
.build()
.encode()
.toUri()
val webClient: WebClient = this.webClientBuilder.build()
return webClient.post()
.uri(uri)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(city)
.exchange()
.flatMap { clientResponse ->
clientResponse.bodyToMono(City::class.java)
}
}
}
See how the intent is expressed in a fluent way. The URI and the headers are first being set; the request body is then put in place and the response is unmarshalled back to a "City" response type.
All is well and good. Now, what does a test look like?
I am using the excellent Wiremock to bring up a dummy remote service and using this CitiesClient
to send the request, along these lines:
xxxxxxxxxx
class WebClientConfigurationTest {
private lateinit var webClientBuilder: WebClient.Builder
private lateinit var objectMapper: ObjectMapper
fun testAPost() {
val dateAsString = "1985-02-01T10:10:10Z"
val city = City(
id = 1L, name = "some city",
country = "some country",
pop = 1000L,
creationDate = Instant.parse(dateAsString)
)
WIREMOCK_SERVER.stubFor(
post(urlMatching("/cities"))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Content-Type", equalTo("application/json"))
.willReturn(
aResponse()
.withHeader("Content-Type", "application/json")
.withStatus(HttpStatus.CREATED.value())
.withBody(objectMapper.writeValueAsString(city))
)
)
val citiesClient = CitiesClient(webClientBuilder, "http://localhost:${WIREMOCK_SERVER.port()}")
val citiesMono: Mono<City> = citiesClient.createCity(city)
StepVerifier
.create(citiesMono)
.expectNext(city)
.expectComplete()
.verify()
//Ensure that date field is in ISO-8601 format..
WIREMOCK_SERVER.verify(
postRequestedFor(urlPathMatching("/cities"))
.withRequestBody(matchingJsonPath("$.creationDate", equalTo(dateAsString)))
)
}
companion object {
private val WIREMOCK_SERVER =
WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort().notifier(ConsoleNotifier(true)))
fun beforeAll() {
WIREMOCK_SERVER.start()
}
fun afterAll() {
WIREMOCK_SERVER.stop()
}
}
}
In the highlighted lines, I want to make sure that the remote service receives the date in ISO-8601 format as "1985-02-01T10:10:10Z". In this instance, everything works cleanly and the test passes.
Not-So-Happy Path
Consider now a case where I have customized the WebClient.Builder
in some form. Here's an example. Say I am using a registry service and I want to look up a remote service via this registry and then make a call. Then, the WebClient
has to be customized to add a @LoadBalanced
annotation on it. More details can be found here.
So say I customized WebClient.Builder
this way:
xxxxxxxxxx
class WebClientConfiguration {
fun webClientBuilder(): WebClient.Builder {
return WebClient.builder().filter { req, next ->
LOGGER.error("Custom filter invoked..")
next.exchange(req)
}
}
companion object {
val LOGGER = loggerFor<WebClientConfiguration>()
}
}
It looks straightforward. However, now, the previous test fails. Specifically, the date format of the creationDate
over the wire is not ISO-8601 anymore. The raw request looks like this:
xxxxxxxxxx
{
"id": 1,
"name": "some city",
"country": "some country",
"pop": 1000,
"creationDate": 476100610.000000000
}
And here's what it looks like for a working request:
xxxxxxxxxx
{
"id": 1,
"name": "some city",
"country": "some country",
"pop": 1000,
"creationDate": "1985-02-01T10:10:10Z"
}
See how the date format is different?
Problem
The underlying reason for this issue is simple: Spring Boot adds a bunch of configuration on WebClient.Builder
that is lost when I have explicitly created the bean myself. Specifically, in this instance, there is a Jackson ObjectMapper
created under the covers, which, by default, writes dates as timestamps. More details can be found here.
Solution
Okay, so how do we retrieve customizations made in Spring Boot? I have essentially replicated the behavior of auto-configuration in Spring called WebClientAutoConfiguration
, and it looks like this:
xxxxxxxxxx
class WebClientConfiguration {
fun webClientBuilder(customizerProvider: ObjectProvider<WebClientCustomizer>): WebClient.Builder {
val webClientBuilder: WebClient.Builder = WebClient
.builder()
.filter { req, next ->
LOGGER.error("Custom filter invoked..")
next.exchange(req)
}
customizerProvider.orderedStream()
.forEach { customizer -> customizer.customize(webClientBuilder) }
return webClientBuilder;
}
companion object {
val LOGGER = loggerFor<WebClientConfiguration>()
}
}
There is a likely a better approach than just replicating this behavior, but this approach works for me.
The posted content now looks like this:
xxxxxxxxxx
{
"id": 1,
"name": "some city",
"country": "some country",
"pop": 1000,
"creationDate": "1985-02-01T10:10:10Z"
}
... with the date back in the right format.
Conclusion
Spring Boot's auto-configurations for WebClient
provides an opinionated set of defaults. If for any reason the WebClient
and it's builder need to be configured explicitly, then be wary of some of the customizations that Spring Boot adds and replicate it for the customized bean. In my case, the Jackson customization for Java 8 dates was missing in my custom WebClient.Builder
and had to be explicitly accounted for.
A sample test and customization is available here.
Thanks for reading!
Further Reading
Java 8 Date and Time
Spring Boot WebClient and Unit Testing
Reactive Programming in Java: Using the WebClient Class