Handle form and query parameters separately

Previously, form and query parameters were handled together as
request parameters. Howeer, request parameters are a server-side
construct that's specific to the servlet specification. As such
they're not appropriate for the client-side documentation that
Spring REST Docs aims to produce.

This commit replaces support for documenting request parameters
with support for documenting query paramters found in the query
string of the request's URI and for documenting form parameters
found in the form URL encoded body of the request.

Closes gh-832
This commit is contained in:
Andy Wilkinson
2022-10-10 15:28:30 +01:00
parent b4be34bf8e
commit f5a629af34
63 changed files with 1730 additions and 1356 deletions

View File

@@ -17,14 +17,18 @@
package org.springframework.restdocs.mockmvc;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Scanner;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Part;
@@ -39,10 +43,11 @@ import org.springframework.restdocs.operation.OperationRequest;
import org.springframework.restdocs.operation.OperationRequestFactory;
import org.springframework.restdocs.operation.OperationRequestPart;
import org.springframework.restdocs.operation.OperationRequestPartFactory;
import org.springframework.restdocs.operation.Parameters;
import org.springframework.restdocs.operation.RequestConverter;
import org.springframework.restdocs.operation.RequestCookie;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@@ -55,36 +60,88 @@ import org.springframework.web.multipart.MultipartFile;
*/
class MockMvcRequestConverter implements RequestConverter<MockHttpServletRequest> {
private static final String SCHEME_HTTP = "http";
private static final String SCHEME_HTTPS = "https";
private static final int STANDARD_PORT_HTTP = 80;
private static final int STANDARD_PORT_HTTPS = 443;
@Override
public OperationRequest convert(MockHttpServletRequest mockRequest) {
try {
HttpHeaders headers = extractHeaders(mockRequest);
Parameters parameters = extractParameters(mockRequest);
List<OperationRequestPart> parts = extractParts(mockRequest);
Collection<RequestCookie> cookies = extractCookies(mockRequest, headers);
String queryString = mockRequest.getQueryString();
if (!StringUtils.hasText(queryString) && "GET".equals(mockRequest.getMethod())) {
queryString = parameters.toQueryString();
}
return new OperationRequestFactory().create(
URI.create(
getRequestUri(mockRequest) + (StringUtils.hasText(queryString) ? "?" + queryString : "")),
HttpMethod.valueOf(mockRequest.getMethod()), mockRequest.getContentAsByteArray(), headers,
parameters, parts, cookies);
return new OperationRequestFactory().create(getRequestUri(mockRequest),
HttpMethod.valueOf(mockRequest.getMethod()), getRequestContent(mockRequest, headers), headers,
parts, cookies);
}
catch (Exception ex) {
throw new ConversionException(ex);
}
}
private URI getRequestUri(MockHttpServletRequest mockRequest) {
String queryString = "";
if (mockRequest.getQueryString() != null) {
queryString = mockRequest.getQueryString();
}
else if ("GET".equals(mockRequest.getMethod()) || mockRequest.getContentLengthLong() > 0) {
queryString = urlEncodedParameters(mockRequest);
}
StringBuffer requestUrlBuffer = mockRequest.getRequestURL();
if (queryString.length() > 0) {
requestUrlBuffer.append("?").append(queryString.toString());
}
return URI.create(requestUrlBuffer.toString());
}
private String urlEncodedParameters(MockHttpServletRequest mockRequest) {
StringBuilder parameters = new StringBuilder();
MultiValueMap<String, String> queryParameters = parse(mockRequest.getQueryString());
for (String name : IterableEnumeration.of(mockRequest.getParameterNames())) {
if (!queryParameters.containsKey(name)) {
String[] values = mockRequest.getParameterValues(name);
if (values.length == 0) {
append(parameters, name);
}
else {
for (String value : values) {
append(parameters, name, value);
}
}
}
}
return parameters.toString();
}
private byte[] getRequestContent(MockHttpServletRequest mockRequest, HttpHeaders headers) {
byte[] content = mockRequest.getContentAsByteArray();
if ("GET".equals(mockRequest.getMethod())) {
return content;
}
MediaType contentType = headers.getContentType();
if (contentType == null || MediaType.APPLICATION_FORM_URLENCODED.includes(contentType)) {
Map<String, String[]> parameters = mockRequest.getParameterMap();
if (!parameters.isEmpty() && (content == null || content.length == 0)) {
StringBuilder contentBuilder = new StringBuilder();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> queryParameters = parse(mockRequest.getQueryString());
mockRequest.getParameterMap().forEach((name, values) -> {
List<String> queryParameterValues = queryParameters.get(name);
if (values.length == 0) {
if (queryParameterValues == null) {
append(contentBuilder, name);
}
}
else {
for (String value : values) {
if (queryParameterValues == null || !queryParameterValues.contains(value)) {
append(contentBuilder, name, value);
}
}
}
});
return contentBuilder.toString().getBytes(StandardCharsets.UTF_8);
}
}
return content;
}
private Collection<RequestCookie> extractCookies(MockHttpServletRequest mockRequest, HttpHeaders headers) {
if (mockRequest.getCookies() == null || mockRequest.getCookies().length == 0) {
return Collections.emptyList();
@@ -158,16 +215,6 @@ class MockMvcRequestConverter implements RequestConverter<MockHttpServletRequest
return partHeaders;
}
private Parameters extractParameters(MockHttpServletRequest servletRequest) {
Parameters parameters = new Parameters();
for (String name : IterableEnumeration.of(servletRequest.getParameterNames())) {
for (String value : servletRequest.getParameterValues(name)) {
parameters.add(name, value);
}
}
return parameters;
}
private HttpHeaders extractHeaders(MockHttpServletRequest servletRequest) {
HttpHeaders headers = new HttpHeaders();
for (String headerName : IterableEnumeration.of(servletRequest.getHeaderNames())) {
@@ -178,21 +225,62 @@ class MockMvcRequestConverter implements RequestConverter<MockHttpServletRequest
return headers;
}
private boolean isNonStandardPort(MockHttpServletRequest request) {
return (SCHEME_HTTP.equals(request.getScheme()) && request.getServerPort() != STANDARD_PORT_HTTP)
|| (SCHEME_HTTPS.equals(request.getScheme()) && request.getServerPort() != STANDARD_PORT_HTTPS);
private static void append(StringBuilder sb, String key) {
append(sb, key, "");
}
private String getRequestUri(MockHttpServletRequest request) {
StringWriter uriWriter = new StringWriter();
PrintWriter printer = new PrintWriter(uriWriter);
private static void append(StringBuilder sb, String key, String value) {
doAppend(sb, urlEncode(key) + "=" + urlEncode(value));
}
printer.printf("%s://%s", request.getScheme(), request.getServerName());
if (isNonStandardPort(request)) {
printer.printf(":%d", request.getServerPort());
private static void doAppend(StringBuilder sb, String toAppend) {
if (sb.length() > 0) {
sb.append("&");
}
printer.print(request.getRequestURI());
return uriWriter.toString();
sb.append(toAppend);
}
private static String urlEncode(String s) {
if (!StringUtils.hasLength(s)) {
return "";
}
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
private static MultiValueMap<String, String> parse(String query) {
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
if (!StringUtils.hasLength(query)) {
return parameters;
}
try (Scanner scanner = new Scanner(query)) {
scanner.useDelimiter("&");
while (scanner.hasNext()) {
processParameter(scanner.next(), parameters);
}
}
return parameters;
}
private static void processParameter(String parameter, MultiValueMap<String, String> parameters) {
String[] components = parameter.split("=");
if (components.length > 0 && components.length < 3) {
if (components.length == 2) {
String name = components[0];
String value = components[1];
parameters.add(decode(name), decode(value));
}
else {
List<String> values = parameters.computeIfAbsent(components[0], (p) -> new LinkedList<>());
values.add("");
}
}
else {
throw new IllegalArgumentException("The parameter '" + parameter + "' is malformed");
}
}
private static String decode(String encoded) {
return URLDecoder.decode(encoded, StandardCharsets.US_ASCII);
}
}

View File

@@ -127,31 +127,35 @@ public class MockMvcRequestConverterTests {
OperationRequest request = createOperationRequest(
MockMvcRequestBuilders.get("/foo").param("a", "alpha", "apple").param("b", "br&vo"));
assertThat(request.getUri()).isEqualTo(URI.create("http://localhost/foo?a=alpha&a=apple&b=br%26vo"));
assertThat(request.getParameters().size()).isEqualTo(2);
assertThat(request.getParameters()).containsEntry("a", Arrays.asList("alpha", "apple"));
assertThat(request.getParameters()).containsEntry("b", Arrays.asList("br&vo"));
assertThat(request.getMethod()).isEqualTo(HttpMethod.GET);
}
@Test
public void getRequestWithQueryStringPopulatesParameters() {
OperationRequest request = createOperationRequest(MockMvcRequestBuilders.get("/foo?a=alpha&b=bravo"));
public void getRequestWithQueryString() {
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/foo?a=alpha&b=bravo");
OperationRequest request = createOperationRequest(builder);
assertThat(request.getUri()).isEqualTo(URI.create("http://localhost/foo?a=alpha&b=bravo"));
assertThat(request.getParameters().size()).isEqualTo(2);
assertThat(request.getParameters()).containsEntry("a", Arrays.asList("alpha"));
assertThat(request.getParameters()).containsEntry("b", Arrays.asList("bravo"));
assertThat(request.getMethod()).isEqualTo(HttpMethod.GET);
}
@Test
public void postRequestWithParameters() {
public void postRequestWithParametersCreatesFormUrlEncodedContent() {
OperationRequest request = createOperationRequest(
MockMvcRequestBuilders.post("/foo").param("a", "alpha", "apple").param("b", "br&vo"));
assertThat(request.getUri()).isEqualTo(URI.create("http://localhost/foo"));
assertThat(request.getMethod()).isEqualTo(HttpMethod.POST);
assertThat(request.getParameters().size()).isEqualTo(2);
assertThat(request.getParameters()).containsEntry("a", Arrays.asList("alpha", "apple"));
assertThat(request.getParameters()).containsEntry("b", Arrays.asList("br&vo"));
assertThat(request.getContentAsString()).isEqualTo("a=alpha&a=apple&b=br%26vo");
assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED);
}
@Test
public void postRequestWithParametersAndQueryStringCreatesFormUrlEncodedContentWithoutDuplication() {
OperationRequest request = createOperationRequest(
MockMvcRequestBuilders.post("/foo?a=alpha").param("a", "apple").param("b", "br&vo"));
assertThat(request.getUri()).isEqualTo(URI.create("http://localhost/foo?a=alpha"));
assertThat(request.getMethod()).isEqualTo(HttpMethod.POST);
assertThat(request.getContentAsString()).isEqualTo("a=apple&b=br%26vo");
assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_FORM_URLENCODED);
}
@Test

View File

@@ -92,7 +92,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.subsecti
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.restdocs.request.RequestDocumentation.requestParameters;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.restdocs.request.RequestDocumentation.requestParts;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -178,7 +178,7 @@ public class MockMvcRestDocumentationIntegrationTests {
public void curlSnippetWithQueryStringOnPost() throws Exception {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
.apply(documentationConfiguration(this.restDocumentation)).build();
mockMvc.perform(post("/?foo=bar").param("foo", "bar").param("a", "alpha").accept(MediaType.APPLICATION_JSON))
mockMvc.perform(post("/?foo=bar").param("a", "alpha").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()).andDo(document("curl-snippet-with-query-string"));
assertThat(new File("build/generated-snippets/curl-snippet-with-query-string/curl-request.adoc"))
.has(content(codeBlock(TemplateFormats.asciidoctor(), "bash")
@@ -238,11 +238,11 @@ public class MockMvcRestDocumentationIntegrationTests {
public void httpieSnippetWithQueryStringOnPost() throws Exception {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
.apply(documentationConfiguration(this.restDocumentation)).build();
mockMvc.perform(post("/?foo=bar").param("foo", "bar").param("a", "alpha").accept(MediaType.APPLICATION_JSON))
mockMvc.perform(post("/?foo=bar").param("a", "alpha").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()).andDo(document("httpie-snippet-with-query-string"));
assertThat(new File("build/generated-snippets/httpie-snippet-with-query-string/httpie-request.adoc"))
.has(content(codeBlock(TemplateFormats.asciidoctor(), "bash")
.withContent(String.format("$ http " + "--form POST 'http://localhost:8080/?foo=bar' \\%n"
.withContent(String.format("$ http --form POST 'http://localhost:8080/?foo=bar' \\%n"
+ " 'Accept:application/json' \\%n 'a=alpha'"))));
}
@@ -281,13 +281,13 @@ public class MockMvcRestDocumentationIntegrationTests {
}
@Test
public void requestParametersSnippet() throws Exception {
public void queryParametersSnippet() throws Exception {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
.apply(documentationConfiguration(this.restDocumentation)).build();
mockMvc.perform(get("/").param("foo", "bar").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk())
.andDo(document("links", requestParameters(parameterWithName("foo").description("The description"))));
.andDo(document("links", queryParameters(parameterWithName("foo").description("The description"))));
assertExpectedSnippetFilesExist(new File("build/generated-snippets/links"), "http-request.adoc",
"http-response.adoc", "curl-request.adoc", "request-parameters.adoc");
"http-response.adoc", "curl-request.adoc", "query-parameters.adoc");
}
@Test