URI encoding vs x-www-form-urlencoded: how a simple '+' broke our API
Tom Wetjens
For such a widely used piece of data like a URI, surely we must have water tight specifications right?
According to RFC 3986: Uniform Resource Identifier (URI) (2005)
+ is a reserved character which, if not used as a delimiter, must be percent-encoded as %2B.
However
RFC 1866: Hypertext Markup Language - 2.0 (1995)
gives us the option to submit application/x-www-form-urlencoded forms as GET requests where spaces are represented by +.
This gives an interesting conflict that many web frameworks like Spring and HTTP libraries (like Jetty) have to address.
Most choose to be compatible with RFC 1866 and decode any + character in the query string as a space.
–
The error
We encountered this error in a Spring Boot service:
Parse attempt failed for value [2025-11-12T09:19:55.7352902 01:00]"}
It turns out what is actually sent over the wire is: GET /?at=2025-11-12T09:19:55.7352902+01:00.
Spring Framework, conforming to RFC 1866 decodes the + character in the URL to a space.
The root cause
We traced it all the way back to a bug in OpenAPI Generator we use in our project, to generate a RestClient in Kotlin based on the OpenAPI specification of a service we are calling.
In ApiClient.kt.mustache of the kotlin/jvm-spring-restclient library generates the following code:
inline fun <reified T: Any> parseDateToQueryString(value : T): String {
return Serializer.jacksonObjectMapper.writeValueAsString(value).replace("\"", "")
}
This code incorrectly produces 2025-11-12T09:19:55.7352902+01:00 instead of the correct 2025-11-12T09%3A193A55.7352902%2B013A00, for query parameters of type date-time.
We reported this issue on GitHub: https://github.com/OpenAPITools/openapi-generator/issues/22339
In the meantime we work around it by registering an interceptor on the RestClient that replaces + with %2B.
–
The automated test
The weird thing was: we had an automated test covering this functionality. Why didn’t it fail?
We are using WireMock to mock the API that the generated client is calling. And we adjusted the stub mapping and added a withQueryParam and after to expect a sensible query parameter value:
wireMockServer.stubFor(
get(urlPathTemplate("/"))
.withQueryParam("at", after(timestamp))
.willReturn(okJson(result))
)
The automated test was still passing: with and without a fix. I want my automated test to fail before applying the fix!
After some debugging I found that WireMock is doing some decoding, but not always:
- all query parameters are URL decoded by default, except:
- if the query parameter is an unencoded ISO datetime, then the query parameter is not decoded!
This is done in the com.github.tomakehurst.wiremock.common.Urls class:
private static String decode(String encoded) {
if (!isISOOffsetDateTime(encoded)) {
return URLDecoder.decode(encoded, UTF_8);
}
return encoded;
}
So this explains why my withQueryParam matcher always matches: it always sees a correctly formatted ISO datetime value.
This also means that, unfortunately, I cannot use WireMock to test this specific situation.