From ecfc7abd922fd8a3e7cc91f87ec541e6d189fef3 Mon Sep 17 00:00:00 2001 From: Andreas Evers Date: Sun, 22 Feb 2015 23:48:29 +0100 Subject: [PATCH] Initial support for documenting fields in requests and responses This commit introduces support for documenting the fields in a request and response, modelled on the existing support for documenting links. A test with field documentation enabled will fail if a documented field is missing from the request or response, or if a field present in the request or response has not been documented. See gh-24 Closes gh-25 --- .../RestDocumentationResultHandler.java | 44 ++++++ .../hypermedia/HypermediaDocumentation.java | 1 - .../springframework/restdocs/state/Field.java | 95 ++++++++++++ .../restdocs/state/FieldDescriptor.java | 105 +++++++++++++ .../restdocs/state/FieldExtractor.java | 86 +++++++++++ .../state/FieldSnippetResultHandler.java | 98 ++++++++++++ .../springframework/restdocs/state/Path.java | 124 ++++++++++++++++ .../restdocs/state/StateDocumentation.java | 94 ++++++++++++ .../state/StateDocumentationValidator.java | 75 ++++++++++ .../restdocs/state/FieldExtractorTests.java | 139 ++++++++++++++++++ .../StateDocumentationValidatorTests.java | 93 ++++++++++++ ...ultiple-fields-and-embedded-and-links.json | 9 ++ .../multiple-fields-and-embedded.json | 5 + .../multiple-fields-and-links.json | 8 + .../field-payloads/multiple-fields.json | 19 +++ .../resources/field-payloads/no-fields.json | 1 + .../field-payloads/single-field.json | 3 + 17 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/Field.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldDescriptor.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldExtractor.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldSnippetResultHandler.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/Path.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentation.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentationValidator.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/state/FieldExtractorTests.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/state/StateDocumentationValidatorTests.java create mode 100644 spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded-and-links.json create mode 100644 spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded.json create mode 100644 spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-links.json create mode 100644 spring-restdocs/src/test/resources/field-payloads/multiple-fields.json create mode 100644 spring-restdocs/src/test/resources/field-payloads/no-fields.json create mode 100644 spring-restdocs/src/test/resources/field-payloads/single-field.json diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/RestDocumentationResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/RestDocumentationResultHandler.java index 571f2bec..bcf3675d 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/RestDocumentationResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/RestDocumentationResultHandler.java @@ -20,6 +20,8 @@ import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlRe import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlRequestAndResponse; import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlResponse; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.documentLinks; +import static org.springframework.restdocs.state.StateDocumentation.documentRequestFields; +import static org.springframework.restdocs.state.StateDocumentation.documentResponseFields; import java.util.ArrayList; import java.util.List; @@ -28,6 +30,9 @@ import org.springframework.restdocs.hypermedia.HypermediaDocumentation; import org.springframework.restdocs.hypermedia.LinkDescriptor; import org.springframework.restdocs.hypermedia.LinkExtractor; import org.springframework.restdocs.hypermedia.LinkExtractors; +import org.springframework.restdocs.state.FieldDescriptor; +import org.springframework.restdocs.state.Path; +import org.springframework.restdocs.state.StateDocumentation; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultHandler; @@ -35,6 +40,7 @@ import org.springframework.test.web.servlet.ResultHandler; * A Spring MVC Test {@code ResultHandler} for documenting RESTful APIs. * * @author Andy Wilkinson + * @author Andreas Evers * @see RestDocumentation#document(String) */ public class RestDocumentationResultHandler implements ResultHandler { @@ -97,4 +103,42 @@ public class RestDocumentationResultHandler implements ResultHandler { return this; } + /** + * Document the fields in the response using the given {@code descriptors}. The fields + * are extracted from the response based on its content type. + *

+ * If a field is present in the response but is not described by one of the + * descriptors a failure will occur when this handler is invoked. Similarly, if a + * field is described but is not present in the response a failure will also occur + * when this handler is invoked. + * + * @param descriptors the link descriptors + * @return {@code this} + * @see StateDocumentation#fieldWithPath(Path) + */ + public RestDocumentationResultHandler withRequestFields( + FieldDescriptor... descriptors) { + this.delegates.add(documentRequestFields(this.outputDir, descriptors)); + return this; + } + + /** + * Document the fields in the response using the given {@code descriptors}. The fields + * are extracted from the response based on its content type. + *

+ * If a field is present in the response but is not described by one of the + * descriptors a failure will occur when this handler is invoked. Similarly, if a + * field is described but is not present in the response a failure will also occur + * when this handler is invoked. + * + * @param descriptors the link descriptors + * @return {@code this} + * @see StateDocumentation#fieldWithPath(Path) + */ + public RestDocumentationResultHandler withResponseFields( + FieldDescriptor... descriptors) { + this.delegates.add(documentResponseFields(this.outputDir, descriptors)); + return this; + } + } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/HypermediaDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/HypermediaDocumentation.java index 65a51bd0..2393fa62 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/HypermediaDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/HypermediaDocumentation.java @@ -59,5 +59,4 @@ public abstract class HypermediaDocumentation { return new LinkSnippetResultHandler(outputDir, linkExtractor, Arrays.asList(descriptors)); } - } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/Field.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/Field.java new file mode 100644 index 00000000..e0b124fe --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/Field.java @@ -0,0 +1,95 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import org.springframework.core.style.ToStringCreator; + +/** + * Representation of a field used in a Hypermedia-based API + * + * @author Andreas Evers + */ +public class Field { + + private final Path path; + + private final Object value; + + /** + * Creates a new {@code Field} with the given {@code path} and {@code value} + * + * @param path The field's path + * @param value The field's value + */ + public Field(Path path, Object value) { + this.path = path; + this.value = value; + } + + /** + * Returns the field's {@code path} + * @return the field's {@code path} + */ + public Path getPath() { + return this.path; + } + + /** + * Returns the field's {@code value} + * @return the field's {@code value} + */ + public Object getValue() { + return this.value; + } + + @Override + public int hashCode() { + int prime = 31; + int result = 1; + result = prime * result + this.path.hashCode(); + result = prime * result + this.value.hashCode(); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Field other = (Field) obj; + if (!this.path.equals(other.path)) { + return false; + } + if (!this.value.equals(other.value)) { + return false; + } + return true; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("path", this.path) + .append("value", this.value).toString(); + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldDescriptor.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldDescriptor.java new file mode 100644 index 00000000..8b9cc9ff --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldDescriptor.java @@ -0,0 +1,105 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +/** + * A description of a field found in a hypermedia API + * + * @see StateDocumentation#fieldWithPath(Path) + * + * @author Andreas Evers + */ +public class FieldDescriptor { + + private final Path path; + + private String type; + + private boolean required; + + private String constraints; + + private String description; + + FieldDescriptor(Path path) { + this.path = path; + } + + /** + * Specifies the type of the field + * + * @param type The field's type (could be number, string, boolean, array, object, ...) + * @return {@code this} + */ + public FieldDescriptor type(String type) { + this.type = type; + return this; + } + + /** + * Specifies necessity of the field + * + * @param required The field's necessity + * @return {@code this} + */ + public FieldDescriptor required(boolean required) { + this.required = required; + return this; + } + + /** + * Specifies the constraints of the field + * + * @param constraints The field's constraints + * @return {@code this} + */ + public FieldDescriptor constraints(String constraints) { + this.constraints = constraints; + return this; + } + + /** + * Specifies the description of the field + * + * @param description The field's description + * @return {@code this} + */ + public FieldDescriptor description(String description) { + this.description = description; + return this; + } + + Path getPath() { + return this.path; + } + + String getType() { + return this.type; + } + + boolean isRequired() { + return this.required; + } + + String getConstraints() { + return this.constraints; + } + + String getDescription() { + return this.description; + } +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldExtractor.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldExtractor.java new file mode 100644 index 00000000..a46b2f87 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldExtractor.java @@ -0,0 +1,86 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * A {@code FieldExtractor} is used to extract {@link Field fields} from a JSON response. + * The expected format of the links in the response is determined by the implementation. + * + * @author Andy Wilkinson + * + */ +public class FieldExtractor { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private Map extractedFields = new HashMap<>(); + + @SuppressWarnings("unchecked") + public Map extractFields(MockHttpServletRequest request) + throws IOException { + Map jsonContent = this.objectMapper.readValue( + request.getInputStream(), Map.class); + extractFieldsRecursively(jsonContent); + return this.extractedFields; + } + + @SuppressWarnings("unchecked") + public Map extractFields(MockHttpServletResponse response) + throws IOException { + String responseBody = response.getContentAsString(); + Assert.hasText(responseBody, + "The response doesn't contain a body to extract fields from"); + Map jsonContent = this.objectMapper.readValue(responseBody, + Map.class); + extractFieldsRecursively(jsonContent); + return this.extractedFields; + } + + private void extractFieldsRecursively(Map jsonContent) { + extractFieldsRecursively(null, jsonContent); + } + + @SuppressWarnings("unchecked") + private void extractFieldsRecursively(Path previousSteps, + Map jsonContent) { + for (Entry entry : jsonContent.entrySet()) { + Path path; + if (previousSteps == null) { + path = new Path(entry.getKey()); + } + else { + path = new Path(previousSteps, entry.getKey()); + } + this.extractedFields.put(path, new Field(path, entry.getValue())); + if (entry.getValue() instanceof Map) { + Map value = (Map) entry.getValue(); + extractFieldsRecursively(path, value); + } + } + } +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldSnippetResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldSnippetResultHandler.java new file mode 100644 index 00000000..b77ae64d --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/FieldSnippetResultHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.SortedSet; +import java.util.TreeSet; + +import org.springframework.restdocs.snippet.DocumentationWriter; +import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.util.Assert; + +/** + * A {@link SnippetWritingResultHandler} that produces a snippet documenting a RESTful + * resource's request or response fields. + * + * @author Andreas Evers + */ +public class FieldSnippetResultHandler extends SnippetWritingResultHandler { + + private final Map descriptorsByName = new HashMap(); + + private final Type type; + + private FieldExtractor extractor = new FieldExtractor(); + + private StateDocumentationValidator validator; + + enum Type { + REQUEST, RESPONSE; + } + + FieldSnippetResultHandler(String outputDir, FieldSnippetResultHandler.Type type, + List descriptors) { + super(outputDir, type.toString().toLowerCase() + "fields"); + this.type = type; + for (FieldDescriptor descriptor : descriptors) { + Assert.notNull(descriptor.getPath()); + Assert.hasText(descriptor.getDescription()); + this.descriptorsByName.put(descriptor.getPath(), descriptor); + } + this.validator = new StateDocumentationValidator(type); + } + + @Override + protected void handle(MvcResult result, DocumentationWriter writer) + throws IOException { + Map fields; + if (this.type == REQUEST) { + fields = this.extractor.extractFields(result.getRequest()); + } + else { + fields = this.extractor.extractFields(result.getResponse()); + } + + SortedSet actualFields = new TreeSet(fields.keySet()); + SortedSet expectedFields = new TreeSet( + this.descriptorsByName.keySet()); + + this.validator.validateFields(actualFields, expectedFields); + + writer.println("|==="); + writer.println("| Path | Description | Type | Required | Constraints"); + + for (Entry entry : this.descriptorsByName.entrySet()) { + writer.println(); + writer.println("| " + entry.getKey()); + writer.println("| " + entry.getValue().getDescription()); + writer.println("| " + entry.getValue().getType()); + writer.println("| " + entry.getValue().isRequired()); + writer.println("| " + entry.getValue().getConstraints()); + } + + writer.println("|==="); + } + +} \ No newline at end of file diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/Path.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/Path.java new file mode 100644 index 00000000..a82557ac --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/Path.java @@ -0,0 +1,124 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import static java.lang.Math.min; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.style.ToStringCreator; + +/** + * Representation of a path for a field. In case the field is a nested field, there will + * be multiple steps. Each step is the name of a field, albeit parents of the field in + * question. In case the field is not nested, there is only one step, which is the name of the field. + * + * @author Andreas Evers + */ +public class Path implements Comparable { + + private List steps = new ArrayList<>(); + + public Path(Path path) { + this.steps = new ArrayList(path.getSteps()); + } + + public Path(List steps) { + this.steps = steps; + } + + public Path(Path previousSteps, String newStep) { + this.steps.addAll(previousSteps.getSteps()); + this.steps.add(newStep); + } + + public Path(String... steps) { + this.steps = Arrays.asList(steps); + } + + public static Path path(List steps) { + return new Path(steps); + } + + public static Path path(String... steps) { + return new Path(steps); + } + + public static Path path(Path previousSteps, String newStep) { + return new Path(previousSteps, newStep); + } + + public List getSteps() { + return this.steps; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((this.steps == null) ? 0 : this.steps.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Path other = (Path) obj; + if (this.steps == null) { + if (other.steps != null) { + return false; + } + } + else if (!this.steps.equals(other.steps)) { + return false; + } + return true; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("steps", this.steps).toString(); + } + + @Override + public int compareTo(Path o) { + int comparison = 0; + int size = min(this.steps.size(), o.getSteps().size()); + for (int i = 0; i < size; i++) { + String thisStep = this.steps.get(i); + String thatStep = o.getSteps().get(i); + comparison = thisStep.compareTo(thatStep); + if (comparison != 0) { + break; + } + } + if (comparison == 0) { + comparison = ((Integer) this.steps.size()).compareTo(o.getSteps().size()); + } + return comparison; + } +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentation.java new file mode 100644 index 00000000..79777587 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentation.java @@ -0,0 +1,94 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST; +import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.RESPONSE; +import static org.springframework.restdocs.state.Path.path; + +import java.util.Arrays; + +import org.springframework.restdocs.RestDocumentationResultHandler; + +/** + * Static factory methods for documenting a RESTful API's state. + * + * @author Andreas Evers + */ +public abstract class StateDocumentation { + + private StateDocumentation() { + + } + + /** + * Creates a {@code FieldDescriptor} that describes a field with the given + * {@code path}. + * + * @param path The path of the field + * @return a {@code FieldDescriptor} ready for further configuration + * @see RestDocumentationResultHandler#withRequestFields(FieldDescriptor...) + * @see RestDocumentationResultHandler#withResponseFields(FieldDescriptor...) + */ + public static FieldDescriptor fieldWithPath(Path path) { + return new FieldDescriptor(path); + } + + /** + * Creates a {@code FieldDescriptor} that describes a field with the given + * {@code path}, in case the field is at the root of the request or response body + * + * @param name The name of the field being at the root of the request or response body + * @return a {@code FieldDescriptor} ready for further configuration + * @see RestDocumentationResultHandler#withRequestFields(FieldDescriptor...) + * @see RestDocumentationResultHandler#withResponseFields(FieldDescriptor...) + */ + public static FieldDescriptor fieldWithPath(String name) { + return new FieldDescriptor(path(name)); + } + + /** + * Creates a {@code RequestFieldsSnippetResultHandler} that will produce a + * documentation snippet for a request's fields. + * + * @param outputDir The directory to which the snippet should be written + * @param descriptors The descriptions of the request's fields + * @return the handler + * @see RestDocumentationResultHandler#withRequestFields(FieldDescriptor...) + */ + public static FieldSnippetResultHandler documentRequestFields(String outputDir, + FieldDescriptor... descriptors) { + return new FieldSnippetResultHandler(outputDir, REQUEST, + Arrays.asList(descriptors)); + } + + /** + * Creates a {@code ResponseFieldsSnippetResultHandler} that will produce a + * documentation snippet for a response's fields. + * + * @param outputDir The directory to which the snippet should be written + * @param descriptors The descriptions of the response's fields + * @return the handler + * @see RestDocumentationResultHandler#withResponseFields(FieldDescriptor...) + */ + public static FieldSnippetResultHandler documentResponseFields(String outputDir, + FieldDescriptor... descriptors) { + return new FieldSnippetResultHandler(outputDir, RESPONSE, + Arrays.asList(descriptors)); + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentationValidator.java b/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentationValidator.java new file mode 100644 index 00000000..60409cbf --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/state/StateDocumentationValidator.java @@ -0,0 +1,75 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import static org.junit.Assert.fail; + +import java.util.HashSet; +import java.util.Set; +import java.util.SortedSet; + +import org.springframework.restdocs.state.FieldSnippetResultHandler.Type; + +/** + * Validator which verifies if fields are documented correctly. All fields need to be + * documented except nested fields. For those fields it is sufficient that only the parent + * is documented. In case there are fields documented that don't appear in the actual + * request or response, the validation will fail. + * + * @author Andreas Evers + */ +public class StateDocumentationValidator { + + private final Type type; + + public StateDocumentationValidator(Type type) { + this.type = type; + } + + public void validateFields(SortedSet actualFields, + SortedSet expectedFields) { + Set undocumentedFields = new HashSet(actualFields); + Set ignoredFields = new HashSet(); + undocumentedFields.removeAll(expectedFields); + for (Path path : undocumentedFields) { + if (path.getSteps().size() > 1) { + Path wrappingPath = new Path(path); + wrappingPath.getSteps().remove(wrappingPath.getSteps().size() - 1); + if (actualFields.contains(wrappingPath)) { + ignoredFields.add(path); + } + } + } + undocumentedFields.removeAll(ignoredFields); + + Set missingFields = new HashSet(expectedFields); + missingFields.removeAll(actualFields); + + if (!undocumentedFields.isEmpty() || !missingFields.isEmpty()) { + String message = ""; + if (!undocumentedFields.isEmpty()) { + message += "Fields with the following paths were not documented: " + + undocumentedFields; + } + if (!missingFields.isEmpty()) { + message += "Fields with the following paths were not found in the " + + this.type.toString().toLowerCase() + ": " + missingFields; + } + fail(message); + } + } +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/state/FieldExtractorTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/state/FieldExtractorTests.java new file mode 100644 index 00000000..a8533bbe --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/state/FieldExtractorTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2014-2015 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 + * + * http://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.state; + +import static org.junit.Assert.assertEquals; +import static org.springframework.restdocs.state.Path.path; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.util.FileCopyUtils; + +/** + * Tests for {@link FieldExtractor}. + * + * @author Andreas Evers + */ +public class FieldExtractorTests { + + private final FieldExtractor fieldExtractor = new FieldExtractor(); + + @Test + public void singleField() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("single-field")); + assertFields(Arrays.asList(new Field(path("alpha"), "alpha-value")), fields); + } + + @Test + public void multipleFields() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("multiple-fields")); + assertFields(Arrays.asList(new Field(path("alpha"), "alpha-value"), new Field( + path("bravo"), 123), new Field(path("charlie"), createMap()), new Field( + path("delta"), createList()), new Field(path("echo"), + createListWithMaps())), fields); + + } + + private Map createMap() { + Map hashMap = new HashMap<>(); + hashMap.put("one", 456); + hashMap.put("two", "two-value"); + return hashMap; + } + + private List createList() { + List arrayList = new ArrayList<>(); + arrayList.add("delta-value-1"); + arrayList.add("delta-value-2"); + return arrayList; + } + + private List> createListWithMaps() { + List> arrayList = new ArrayList<>(); + Map hashMap1 = new HashMap<>(); + hashMap1.put("one", 789); + hashMap1.put("two", "two-value"); + arrayList.add(hashMap1); + Map hashMap2 = new HashMap<>(); + hashMap2.put("one", 987); + hashMap2.put("two", "value-two"); + arrayList.add(hashMap2); + return arrayList; + } + + @Test + public void multipleFieldsAndLinks() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("multiple-fields-and-links")); + assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field( + path("charlie"), "charlie-value")), fields); + } + + @Test + public void multipleFieldsAndEmbedded() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("multiple-fields-and-embedded")); + assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field( + path("charlie"), "charlie-value")), fields); + } + + @Test + public void multipleFieldsAndEmbeddedAndLinks() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("multiple-fields-and-embedded-and-links")); + assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field( + path("charlie"), "charlie-value")), fields); + } + + @Test + public void noFields() throws IOException { + Map fields = this.fieldExtractor + .extractFields(createResponse("no-fields")); + assertFields(Collections. emptyList(), fields); + } + + private void assertFields(List expectedFields, Map actualFields) { + Map expectedFieldsByName = new HashMap<>(); + for (Field expectedField : expectedFields) { + expectedFieldsByName.put(expectedField.getPath(), expectedField); + } + assertEquals(expectedFieldsByName, actualFields); + } + + private MockHttpServletResponse createResponse(String contentName) throws IOException { + MockHttpServletResponse response = new MockHttpServletResponse(); + FileCopyUtils.copy(new FileReader(getPayloadFile(contentName)), + response.getWriter()); + return response; + } + + private File getPayloadFile(String name) { + return new File("src/test/resources/field-payloads/" + name + ".json"); + } +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/state/StateDocumentationValidatorTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/state/StateDocumentationValidatorTests.java new file mode 100644 index 00000000..38c63505 --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/state/StateDocumentationValidatorTests.java @@ -0,0 +1,93 @@ +package org.springframework.restdocs.state; + +import static java.util.Arrays.asList; +import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST; +import static org.springframework.restdocs.state.Path.path; + +import java.util.SortedSet; +import java.util.TreeSet; + +import org.junit.After; +import org.junit.Test; + +public class StateDocumentationValidatorTests { + + SortedSet actualFields = new TreeSet(); + + SortedSet expectedFields = new TreeSet(); + + StateDocumentationValidator validator = new StateDocumentationValidator(REQUEST); + + @After + public void cleanup() { + this.actualFields = new TreeSet(); + this.expectedFields = new TreeSet(); + } + + @Test + public void equalFields() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test(expected = AssertionError.class) + public void sameLevelButMoreDocumented() { + this.actualFields = new TreeSet(asList(path("alpha"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test(expected = AssertionError.class) + public void sameLevelButMoreActuals() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"))); + this.expectedFields = new TreeSet(asList(path("alpha"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test(expected = AssertionError.class) + public void moreDocumentedButParentPresent() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test + public void moreActualsButParentPresent() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("charlie"), path("charlie", "marco"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test + public void documentationSkippedLevel() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco", "alpha"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } + + @Test(expected = AssertionError.class) + public void moreActualsWithoutParentPresent() { + this.actualFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"))); + this.expectedFields = new TreeSet(asList(path("alpha"), path("bravo"), + path("bravo", "marco"), path("bravo", "polo"), path("charlie"), + path("charlie", "marco"), path("charlie", "marco", "alpha"))); + this.validator.validateFields(this.actualFields, this.expectedFields); + } +} diff --git a/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded-and-links.json b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded-and-links.json new file mode 100644 index 00000000..fdaf197d --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded-and-links.json @@ -0,0 +1,9 @@ +{ + "_links": { + "alpha": "http://alpha.example.com", + "bravo": "http://bravo.example.com" + }, + "_embedded": "embedded-test", + "beta": "beta-value", + "charlie": "charlie-value" +} \ No newline at end of file diff --git a/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded.json b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded.json new file mode 100644 index 00000000..0d4e6b0a --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-embedded.json @@ -0,0 +1,5 @@ +{ + "_embedded": "embedded-test", + "beta": "beta-value", + "charlie": "charlie-value" +} \ No newline at end of file diff --git a/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-links.json b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-links.json new file mode 100644 index 00000000..794ece50 --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/multiple-fields-and-links.json @@ -0,0 +1,8 @@ +{ + "_links": { + "alpha": "http://alpha.example.com", + "bravo": "http://bravo.example.com" + }, + "beta": "beta-value", + "charlie": "charlie-value" +} \ No newline at end of file diff --git a/spring-restdocs/src/test/resources/field-payloads/multiple-fields.json b/spring-restdocs/src/test/resources/field-payloads/multiple-fields.json new file mode 100644 index 00000000..a4d2b54a --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/multiple-fields.json @@ -0,0 +1,19 @@ +{ + "alpha": "alpha-value", + "bravo": 123, + "charlie": { + "one": 456, + "two": "two-value" + }, + "delta": [ + "delta-value-1", + "delta-value-2" + ], + "echo": [{ + "one": 789, + "two": "two-value" + },{ + "one": 987, + "two": "value-two" + }] +} \ No newline at end of file diff --git a/spring-restdocs/src/test/resources/field-payloads/no-fields.json b/spring-restdocs/src/test/resources/field-payloads/no-fields.json new file mode 100644 index 00000000..6f31cf5a --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/no-fields.json @@ -0,0 +1 @@ +{ } \ No newline at end of file diff --git a/spring-restdocs/src/test/resources/field-payloads/single-field.json b/spring-restdocs/src/test/resources/field-payloads/single-field.json new file mode 100644 index 00000000..c209f443 --- /dev/null +++ b/spring-restdocs/src/test/resources/field-payloads/single-field.json @@ -0,0 +1,3 @@ +{ + "alpha": "alpha-value" +} \ No newline at end of file