Last active
January 31, 2020 01:50
-
-
Save bkahlert/47884a44edcbbff8da5e79696f428eff to your computer and use it in GitHub Desktop.
For Spring Boot: unified query parameter parsing with undefined value, like in `?foo&bar=&baz=string`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This code makes sure a query string like
?foo&bar=&baz=string
are always mapped asfoo: null
,bar: ""
andbaz: "string"
as without it's not the case with Spring Boot running on Apache Tomcat. See apache/tomcat#232 for more details.