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

@@ -20,9 +20,12 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import io.restassured.http.Cookie;
@@ -37,11 +40,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.StreamUtils;
import org.springframework.util.StringUtils;
/**
* A converter for creating an {@link OperationRequest} from a REST Assured
@@ -56,7 +59,7 @@ class RestAssuredRequestConverter implements RequestConverter<FilterableRequestS
public OperationRequest convert(FilterableRequestSpecification requestSpec) {
return new OperationRequestFactory().create(URI.create(requestSpec.getURI()),
HttpMethod.valueOf(requestSpec.getMethod()), extractContent(requestSpec), extractHeaders(requestSpec),
extractParameters(requestSpec), extractParts(requestSpec), extractCookies(requestSpec));
extractParts(requestSpec), extractCookies(requestSpec));
}
private Collection<RequestCookie> extractCookies(FilterableRequestSpecification requestSpec) {
@@ -68,7 +71,36 @@ class RestAssuredRequestConverter implements RequestConverter<FilterableRequestS
}
private byte[] extractContent(FilterableRequestSpecification requestSpec) {
return convertContent(requestSpec.getBody());
Object body = requestSpec.getBody();
if (body != null) {
return convertContent(body);
}
StringBuilder parameters = new StringBuilder();
if ("POST".equals(requestSpec.getMethod())) {
appendParameters(parameters, requestSpec.getRequestParams());
}
if (!"GET".equals(requestSpec.getMethod())) {
appendParameters(parameters, requestSpec.getFormParams());
}
return parameters.toString().getBytes(StandardCharsets.ISO_8859_1);
}
private void appendParameters(StringBuilder content, Map<String, ?> parameters) {
for (Entry<String, ?> entry : parameters.entrySet()) {
String name = entry.getKey();
Object value = entry.getValue();
if (value instanceof Iterable) {
for (Object v : (Iterable<?>) value) {
append(content, name, v.toString());
}
}
else if (value != null) {
append(content, name, value.toString());
}
else {
append(content, name);
}
}
}
private byte[] convertContent(Object content) {
@@ -131,28 +163,6 @@ class RestAssuredRequestConverter implements RequestConverter<FilterableRequestS
return HttpHeaders.ACCEPT.equals(header.getName()) && "*/*".equals(header.getValue());
}
private Parameters extractParameters(FilterableRequestSpecification requestSpec) {
Parameters parameters = new Parameters();
for (Entry<String, ?> entry : requestSpec.getQueryParams().entrySet()) {
if (entry.getValue() instanceof Collection) {
Collection<?> queryParams = ((Collection<?>) entry.getValue());
for (Object queryParam : queryParams) {
parameters.add(entry.getKey(), queryParam.toString());
}
}
else {
parameters.add(entry.getKey(), entry.getValue().toString());
}
}
for (Entry<String, ?> entry : requestSpec.getRequestParams().entrySet()) {
parameters.add(entry.getKey(), entry.getValue().toString());
}
for (Entry<String, ?> entry : requestSpec.getFormParams().entrySet()) {
parameters.add(entry.getKey(), entry.getValue().toString());
}
return parameters;
}
private Collection<OperationRequestPart> extractParts(FilterableRequestSpecification requestSpec) {
List<OperationRequestPart> parts = new ArrayList<>();
for (MultiPartSpecification multiPartSpec : requestSpec.getMultiPartParams()) {
@@ -165,4 +175,26 @@ class RestAssuredRequestConverter implements RequestConverter<FilterableRequestS
return parts;
}
private static void append(StringBuilder sb, String key) {
append(sb, key, "");
}
private static void append(StringBuilder sb, String key, String value) {
doAppend(sb, urlEncode(key) + "=" + urlEncode(value));
}
private static void doAppend(StringBuilder sb, String toAppend) {
if (sb.length() > 0) {
sb.append("&");
}
sb.append(toAppend);
}
private static String urlEncode(String s) {
if (!StringUtils.hasLength(s)) {
return "";
}
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,220 @@
/*
* Copyright 2014-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.restassured;
import io.restassured.RestAssured;
import io.restassured.specification.RequestSpecification;
import org.assertj.core.api.AbstractAssert;
import org.junit.ClassRule;
import org.junit.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.restdocs.operation.OperationRequest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests to verify that the understanding of REST Assured's parameter handling behavior is
* correct.
*
* @author Andy Wilkinson
*/
public class RestAssuredParameterBehaviorTests {
private static final MediaType APPLICATION_FORM_URLENCODED_ISO_8859_1 = MediaType
.parseMediaType(MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=ISO-8859-1");
@ClassRule
public static TomcatServer tomcat = new TomcatServer();
private final RestAssuredRequestConverter factory = new RestAssuredRequestConverter();
private OperationRequest request;
private RequestSpecification spec = RestAssured.given().port(tomcat.getPort())
.filter((request, response, context) -> {
this.request = this.factory.convert(request);
return context.next(request, response);
});
@Test
public void queryParameterOnGet() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").get("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.GET);
}
@Test
public void queryParameterOnHead() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").head("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.HEAD);
}
@Test
public void queryParameterOnPost() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").post("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.POST);
}
@Test
public void queryParameterOnPut() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").put("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.PUT);
}
@Test
public void queryParameterOnPatch() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").patch("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.PATCH);
}
@Test
public void queryParameterOnDelete() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").delete("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.DELETE);
}
@Test
public void queryParameterOnOptions() {
this.spec.queryParam("a", "alpha", "apple").queryParam("b", "bravo").options("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.OPTIONS);
}
@Test
public void paramOnGet() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").get("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.GET);
}
@Test
public void paramOnHead() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").head("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.HEAD);
}
@Test
public void paramOnPost() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").post("/form-url-encoded").then().statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.POST);
}
@Test
public void paramOnPut() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").put("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.PUT);
}
@Test
public void paramOnPatch() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").patch("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.PATCH);
}
@Test
public void paramOnDelete() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").delete("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.DELETE);
}
@Test
public void paramOnOptions() {
this.spec.param("a", "alpha", "apple").param("b", "bravo").options("/query-parameter").then().statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.OPTIONS);
}
@Test
public void formParamOnGet() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").get("/query-parameter").then()
.statusCode(200);
assertThatRequest(this.request).hasQueryParametersWithMethod(HttpMethod.GET);
}
@Test
public void formParamOnHead() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").head("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.HEAD);
}
@Test
public void formParamOnPost() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").post("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.POST);
}
@Test
public void formParamOnPut() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").put("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.PUT);
}
@Test
public void formParamOnPatch() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").patch("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.PATCH);
}
@Test
public void formParamOnDelete() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").delete("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.DELETE);
}
@Test
public void formParamOnOptions() {
this.spec.formParam("a", "alpha", "apple").formParam("b", "bravo").options("/form-url-encoded").then()
.statusCode(200);
assertThatRequest(this.request).isFormUrlEncodedWithMethod(HttpMethod.OPTIONS);
}
private OperationRequestAssert assertThatRequest(OperationRequest request) {
return new OperationRequestAssert(request);
}
private static final class OperationRequestAssert extends AbstractAssert<OperationRequestAssert, OperationRequest> {
private OperationRequestAssert(OperationRequest actual) {
super(actual, OperationRequestAssert.class);
}
private void isFormUrlEncodedWithMethod(HttpMethod method) {
assertThat(this.actual.getMethod()).isEqualTo(method);
assertThat(this.actual.getUri().getRawQuery()).isNull();
assertThat(this.actual.getContentAsString()).isEqualTo("a=alpha&a=apple&b=bravo");
assertThat(this.actual.getHeaders().getContentType()).isEqualTo(APPLICATION_FORM_URLENCODED_ISO_8859_1);
}
private void hasQueryParametersWithMethod(HttpMethod method) {
assertThat(this.actual.getMethod()).isEqualTo(method);
assertThat(this.actual.getUri().getRawQuery()).isEqualTo("a=alpha&a=apple&b=bravo");
assertThat(this.actual.getContentAsString()).isEmpty();
}
}
}

View File

@@ -21,7 +21,6 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
@@ -79,8 +78,7 @@ public class RestAssuredRequestConverterTests {
RequestSpecification requestSpec = RestAssured.given().port(tomcat.getPort()).queryParam("foo", "bar");
requestSpec.get("/");
OperationRequest request = this.factory.convert((FilterableRequestSpecification) requestSpec);
assertThat(request.getParameters()).hasSize(1);
assertThat(request.getParameters()).containsEntry("foo", Collections.singletonList("bar"));
assertThat(request.getUri().getRawQuery()).isEqualTo("foo=bar");
}
@Test
@@ -88,26 +86,15 @@ public class RestAssuredRequestConverterTests {
RequestSpecification requestSpec = RestAssured.given().port(tomcat.getPort());
requestSpec.get("/?foo=bar&foo=qix");
OperationRequest request = this.factory.convert((FilterableRequestSpecification) requestSpec);
assertThat(request.getParameters()).hasSize(1);
assertThat(request.getParameters()).containsEntry("foo", Arrays.asList("bar", "qix"));
assertThat(request.getUri().getRawQuery()).isEqualTo("foo=bar&foo=qix");
}
@Test
public void formParameters() {
RequestSpecification requestSpec = RestAssured.given().port(tomcat.getPort()).formParam("foo", "bar");
requestSpec.get("/");
OperationRequest request = this.factory.convert((FilterableRequestSpecification) requestSpec);
assertThat(request.getParameters()).hasSize(1);
assertThat(request.getParameters()).containsEntry("foo", Collections.singletonList("bar"));
}
@Test
public void requestParameters() {
public void paramOnGetRequestIsMappedToQueryString() {
RequestSpecification requestSpec = RestAssured.given().port(tomcat.getPort()).param("foo", "bar");
requestSpec.get("/");
OperationRequest request = this.factory.convert((FilterableRequestSpecification) requestSpec);
assertThat(request.getParameters()).hasSize(1);
assertThat(request.getParameters()).containsEntry("foo", Collections.singletonList("bar"));
assertThat(request.getUri().getRawQuery()).isEqualTo("foo=bar");
}
@Test

View File

@@ -68,7 +68,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.restdocs.restassured.RestAssuredRestDocumentation.document;
import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration;
@@ -143,7 +143,7 @@ public class RestAssuredRestDocumentationIntegrationTests {
.has(content(codeBlock(TemplateFormats.asciidoctor(), "bash")
.withContent(String.format("$ curl " + "'http://localhost:" + tomcat.getPort()
+ "/?foo=bar' -i -X POST \\%n" + " -H 'Accept: application/json' \\%n"
+ " -H 'Content-Type: " + contentType + "' \\%n" + " -d 'a=alpha'"))));
+ " -H 'Content-Type: " + contentType + "' \\%n" + " -d 'foo=bar&a=alpha'"))));
}
@Test
@@ -166,13 +166,13 @@ public class RestAssuredRestDocumentationIntegrationTests {
}
@Test
public void requestParametersSnippet() {
public void queryParametersSnippet() {
given().port(tomcat.getPort()).filter(documentationConfiguration(this.restDocumentation))
.filter(document("request-parameters",
requestParameters(parameterWithName("foo").description("The description"))))
.filter(document("query-parameters",
queryParameters(parameterWithName("foo").description("The description"))))
.accept("application/json").param("foo", "bar").get("/").then().statusCode(200);
assertExpectedSnippetFilesExist(new File("build/generated-snippets/request-parameters"), "http-request.adoc",
"http-response.adoc", "curl-request.adoc", "request-parameters.adoc");
assertExpectedSnippetFilesExist(new File("build/generated-snippets/query-parameters"), "http-request.adoc",
"http-response.adoc", "curl-request.adoc", "query-parameters.adoc");
}
@Test
@@ -385,8 +385,10 @@ public class RestAssuredRestDocumentationIntegrationTests {
@Override
public boolean matches(File value) {
try {
return delegate.matches(FileCopyUtils
.copyToString(new InputStreamReader(new FileInputStream(value), StandardCharsets.UTF_8)));
String copyToString = FileCopyUtils
.copyToString(new InputStreamReader(new FileInputStream(value), StandardCharsets.UTF_8));
System.out.println(copyToString);
return delegate.matches(copyToString);
}
catch (IOException ex) {
fail("Failed to read '" + value + "'", ex);

View File

@@ -17,6 +17,7 @@
package org.springframework.restdocs.restassured;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
@@ -33,6 +34,9 @@ import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
import org.junit.rules.ExternalResource;
import org.springframework.http.MediaType;
import org.springframework.util.FileCopyUtils;
/**
* {@link ExternalResource} that starts and stops a Tomcat server.
*
@@ -53,6 +57,10 @@ class TomcatServer extends ExternalResource {
context.addServletMappingDecoded("/", "test");
this.tomcat.addServlet("/", "set-cookie", new CookiesServlet());
context.addServletMappingDecoded("/set-cookie", "set-cookie");
this.tomcat.addServlet("/", "query-parameter", new QueryParameterServlet());
context.addServletMappingDecoded("/query-parameter", "query-parameter");
this.tomcat.addServlet("/", "form-url-encoded", new FormUrlEncodedServlet());
context.addServletMappingDecoded("/form-url-encoded", "form-url-encoded");
this.tomcat.start();
this.port = this.tomcat.getConnector().getLocalPort();
}
@@ -121,4 +129,33 @@ class TomcatServer extends ExternalResource {
}
private static final class QueryParameterServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (!req.getQueryString().equals("a=alpha&a=apple&b=bravo")) {
throw new ServletException("Incorrect query string");
}
resp.setStatus(200);
}
}
private static final class FormUrlEncodedServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (!MediaType.APPLICATION_FORM_URLENCODED
.isCompatibleWith(MediaType.parseMediaType(req.getContentType()))) {
throw new ServletException("Incorrect Content-Type");
}
String content = FileCopyUtils.copyToString(new InputStreamReader(req.getInputStream()));
if (!"a=alpha&a=apple&b=bravo".equals(content)) {
throw new ServletException("Incorrect body content");
}
resp.setStatus(200);
}
}
}