Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
For Spring Boot: unified query parameter parsing with undefined value, like in `?foo&bar=&baz=string`
import lombok.SneakyThrows;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.MethodParameter;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.lang.NonNull;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.Nullable;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.annotation.RequestParamMapMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Apparently not all web containers parse URL query strings the same way. Undefined parameter values like {@code ?a&}
* or {@code ?a&b=} are treated in some containers as empty strings, whereas in others they are treated as {@code null}.
* In order to make undefined values (e.g. {@code ?a}) distinguishable from empty values (e.g. {@code ?a=}) and in order
* to be in accordance to <a href="https://tools.ietf.org/html/rfc6570">RFC 6570</a> and its <a
* href="https://github.com/uri-templates/uritemplate-spec/wiki/Implementations">many implementations</a> this class
* provides a means to make sure parameter parsing is always handled by Spring and never passed to the web container.
* <p>
* For more information, see <a href="https://github.com/apache/tomcat/pull/232">Fix handling of query parameters with
* no value, like {@code ?foo}</a>. The provided solution is based on
* <a href="https://github.com/spring-projects/spring-boot/issues/5004">Unable to have custom
* RequestMappingHandlerMapping</a> and
* <a href="https://github.com/philwebb/spring-boot/commit/27be0dd9ce911ece0d7855d5483866f19986cf74">Add
* WebMvcRegistrations for custom MVC components</a>.
*/
@Configuration
public class UndefinedToNullMappingWebMvcRegistrationsConfiguration {
public static final String MARKER = UndefinedToNullMappingWebMvcRegistrationsConfiguration.class.getName();
public static final String MARKER_VALUE = UriComponentsBuilder.class.getName();
@Bean
public WebMvcRegistrations undefinedToNullMappingWebMvcRegistrations() {
return new WebMvcRegistrations() {
@Override
public RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() {
return new UndefinedToNullMappingRequestMappingHandlerAdapter();
}
};
}
static class UndefinedToNullMappingRequestMappingHandlerAdapter extends RequestMappingHandlerAdapter {
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
setArgumentResolvers(getArgumentResolvers().stream().map(this::patchResolver).collect(Collectors.toList()));
}
private HandlerMethodArgumentResolver patchResolver(HandlerMethodArgumentResolver resolver) {
return resolver instanceof RequestParamMapMethodArgumentResolver ?
new UndefinedToNullMappingRequestParamMapMethodArgumentResolver(
(RequestParamMapMethodArgumentResolver) resolver) : resolver;
}
}
private static class UndefinedToNullMappingRequestParamMapMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final RequestParamMapMethodArgumentResolver resolver;
public UndefinedToNullMappingRequestParamMapMethodArgumentResolver(RequestParamMapMethodArgumentResolver resolver) {
this.resolver = resolver;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return resolver.supportsParameter(parameter);
}
@Override
public Object resolveArgument(
@NonNull MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
@NonNull NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) {
return redirectMapCall(webRequest)
.map(fixedRequest -> resolveArgumentUnchecked(parameter, mavContainer, fixedRequest, binderFactory))
.orElseGet(() -> resolveArgumentUnchecked(parameter, mavContainer, webRequest, binderFactory));
}
@SneakyThrows
private Object resolveArgumentUnchecked(
MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest request, @Nullable WebDataBinderFactory binderFactory) {
return resolver.resolveArgument(parameter, mavContainer, request, binderFactory);
}
public static Optional<UndefinedToNullMappingWebRequest> redirectMapCall(NativeWebRequest webRequest) {
return Optional.ofNullable(Optional.ofNullable(webRequest)
.filter(HttpServletRequest.class::isInstance)
.map(HttpServletRequest.class::cast)
.map(UndefinedToNullMappingWebRequest::new)
.orElseGet(() -> Optional.ofNullable(webRequest)
.filter(ServletWebRequest.class::isInstance)
.map(ServletWebRequest.class::cast)
.map(ServletRequestAttributes::getRequest)
.map(UndefinedToNullMappingWebRequest::new).orElse(null)));
}
private static class UndefinedToNullMappingWebRequest extends ServletWebRequest {
public UndefinedToNullMappingWebRequest(HttpServletRequest httpServletRequest) {
super(httpServletRequest);
}
/**
* Better don't call {@link HttpServletRequest#getParameterMap()}... you won't be able to distinguish
* between empty and undefined values as in {@code ?a=} and {@code ?a}.
*
* @return
*/
public Map<String, String[]> getParameterMap() {
setAttribute(MARKER, MARKER_VALUE, SCOPE_REQUEST);
MultiValueMap<String, String> parameterMap = UriComponentsBuilder
.fromHttpRequest(new ServletServerHttpRequest(getRequest())).build(true).getQueryParams();
return parameterMap.entrySet().stream().collect(Collectors.toMap(
entry -> decode(entry.getKey()),
entry -> decode(entry.getValue())));
}
@SneakyThrows
@Nullable
private static String decode(String value) {
return value != null ? URLDecoder.decode(value, StandardCharsets.UTF_8.displayName()) : null;
}
private static String[] decode(List<String> values) {
return values.stream().map(UndefinedToNullMappingWebRequest::decode).toArray(String[]::new);
}
}
}
}
package de.lbb.kkb.testsupport.rest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static de.lbb.kkb.testsupport.rest.RestConfiguration.MARKER;
import static de.lbb.kkb.testsupport.rest.RestConfiguration.MARKER_VALUE;
import static de.lbb.kkb.testsupport.testing.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(value = RestConfigurationTest.class)
class RestConfigurationTest {
@TestConfiguration
@Import({RestConfiguration.class, TestController.class})
static class TestConfig {
@Bean
RestTemplateBuilder restTemplateBuilder() {
return new RestTemplateBuilder();
}
}
@Autowired
MockMvc mockMvc;
static AtomicReference<HttpServletRequest> capturedRequest;
@RestController
static class TestController {
@GetMapping("/test")
public String endpoint(@RequestParam MultiValueMap<String, String> values,
HttpServletRequest request) throws Exception {
capturedRequest.set(request);
return new ObjectMapper().writeValueAsString(values.toSingleValueMap());
}
}
@Test
void should_map_non_empty_as_non_empty__empty_as_empty__and_undefined_as_null() throws Exception {
capturedRequest = new AtomicReference<>();
MvcResult mvcResult = mockMvc.perform(get("/test?one=1&zero=&null"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().json("{ 'one': '1', 'zero': '', 'null': null }"))
.andReturn();
assertThat(new ObjectMapper().readValue(mvcResult.getResponse().getContentAsString(),
new TypeReference<Map<String, String>>() {}))
.containsEntry("one", "1")
.containsEntry("zero", "")
.containsEntry("null", null);
capturedRequest.get().getAttribute(MARKER).equals(MARKER_VALUE);
}
}
@bkahlert

This comment has been minimized.

Copy link
Owner Author

@bkahlert bkahlert commented Jan 18, 2020

This code makes sure a query string like ?foo&bar=&baz=string are always mapped as foo: null, bar: "" and baz: "string" as without it's not the case with Spring Boot running on Apache Tomcat. See apache/tomcat#232 for more details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment