From 764daf7c9911856037f90f389960b3ad65b7d900 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 27 Apr 2015 16:43:50 +0100 Subject: [PATCH] Add support for documenting fields in payloads that contain arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the path used to document a field had to be dot-separated, with each segment of the path being used as a key in a map. This made it impossible to document fields in a payload within an array. This commit adds support for using [] in a field’s path to represent an array. For example, the fields in following JSON payload: { "id": 67, “date”: "2015-01-20", "assets": [ { "id":356, "name": "sample" } ] } Can now be documented using the following paths: - id - date - assets[].id - assets[].name Closes gh-60 --- build.gradle | 3 + .../restdocs/payload/FieldExtractor.java | 52 ---- .../restdocs/payload/FieldPath.java | 98 +++++++ .../restdocs/payload/FieldProcessor.java | 277 ++++++++++++++++++ .../payload/FieldSnippetResultHandler.java | 15 - .../restdocs/payload/FieldType.java | 2 +- .../restdocs/payload/FieldTypeResolver.java | 19 +- .../restdocs/payload/FieldValidator.java | 31 +- .../payload/PayloadDocumentation.java | 50 ++++ .../restdocs/payload/FieldPathTests.java | 61 ++++ .../restdocs/payload/FieldProcessorTests.java | 233 +++++++++++++++ .../payload/FieldTypeResolverTests.java | 14 + .../restdocs/payload/FieldValidatorTests.java | 18 +- .../payload/PayloadDocumentationTests.java | 155 ++++++++++ 14 files changed, 924 insertions(+), 104 deletions(-) delete mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldExtractor.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldPath.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldProcessor.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldPathTests.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldProcessorTests.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/payload/PayloadDocumentationTests.java diff --git a/build.gradle b/build.gradle index fa8faa1d..46195c88 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ project(':spring-restdocs') { ext { + hamcrestVersion = '1.3' jacksonVersion = '2.3.4' jacocoVersion = '0.7.2.201409121644' junitVersion = '4.11' @@ -67,6 +68,8 @@ project(':spring-restdocs') { testCompile "org.springframework:spring-webmvc:$springVersion" testCompile "org.springframework.hateoas:spring-hateoas:$springHateoasVersion" testCompile "org.mockito:mockito-core:$mockitoVersion" + testCompile "org.hamcrest:hamcrest-core:$hamcrestVersion" + testCompile "org.hamcrest:hamcrest-library:$hamcrestVersion" } test { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldExtractor.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldExtractor.java deleted file mode 100644 index d3363662..00000000 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldExtractor.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.springframework.restdocs.payload; - -import java.util.Map; - -/** - * A {@link FieldExtractor} extracts a field from a payload - * - * @author Andy Wilkinson - * - */ -class FieldExtractor { - - boolean hasField(String path, Map payload) { - String[] segments = path.indexOf('.') > -1 ? path.split("\\.") - : new String[] { path }; - - Object current = payload; - - for (String segment : segments) { - if (current instanceof Map && ((Map) current).containsKey(segment)) { - current = ((Map) current).get(segment); - } - else { - return false; - } - } - - return true; - } - - Object extractField(String path, Map payload) { - String[] segments = path.indexOf('.') > -1 ? path.split("\\.") - : new String[] { path }; - - Object current = payload; - - for (String segment : segments) { - if (current instanceof Map && ((Map) current).containsKey(segment)) { - current = ((Map) current).get(segment); - } - else { - throw new IllegalArgumentException( - "The payload does not contain a field with the path '" + path - + "'"); - } - } - - return current; - - } - -} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldPath.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldPath.java new file mode 100644 index 00000000..e72c6283 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldPath.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.payload; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A path that identifies a field in a payload + * + * @author Andy Wilkinson + * + */ +class FieldPath { + + private static final Pattern ARRAY_INDEX_PATTERN = Pattern + .compile("\\[([0-9]+|\\*){0,1}\\]"); + + private final String rawPath; + + private final List segments; + + private final boolean precise; + + private FieldPath(String rawPath, List segments, boolean precise) { + this.rawPath = rawPath; + this.segments = segments; + this.precise = precise; + } + + boolean isPrecise() { + return this.precise; + } + + List getSegments() { + return this.segments; + } + + @Override + public String toString() { + return this.rawPath; + } + + static FieldPath compile(String path) { + List segments = extractSegments(path); + return new FieldPath(path, segments, matchesSingleValue(segments)); + } + + static boolean isArraySegment(String segment) { + return ARRAY_INDEX_PATTERN.matcher(segment).matches(); + } + + static boolean matchesSingleValue(List segments) { + for (String segment : segments) { + if (isArraySegment(segment)) { + return false; + } + } + return true; + } + + static List extractSegments(String path) { + Matcher matcher = ARRAY_INDEX_PATTERN.matcher(path); + String processedPath; + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, ".[$1]"); + } + matcher.appendTail(buffer); + + if (buffer.length() > 0) { + processedPath = buffer.toString(); + } + else { + processedPath = path; + } + + return Arrays.asList(processedPath.indexOf('.') > -1 ? processedPath.split("\\.") + : new String[] { processedPath }); + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldProcessor.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldProcessor.java new file mode 100644 index 00000000..70a4f1d0 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldProcessor.java @@ -0,0 +1,277 @@ +/* + * 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.payload; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A {@code FieldProcessor} processes a payload's fields, allowing them to be extracted + * and removed + * + * @author Andy Wilkinson + * + */ +class FieldProcessor { + + boolean hasField(FieldPath fieldPath, Map payload) { + final AtomicReference hasField = new AtomicReference(false); + traverse(new ProcessingContext(payload, fieldPath), new MatchCallback() { + + @Override + public boolean foundMatch(Match match) { + hasField.set(true); + return false; + } + + @Override + public boolean matchNotFound() { + return false; + } + + }); + return hasField.get(); + } + + Object extract(final FieldPath path, Map payload) { + final List matches = new ArrayList(); + traverse(new ProcessingContext(payload, path), new MatchCallback() { + + @Override + public boolean foundMatch(Match match) { + matches.add(match.getValue()); + return true; + } + + @Override + public boolean matchNotFound() { + return false; + } + + }); + if (matches.isEmpty()) { + throw new IllegalArgumentException( + "The payload does not contain a field with the path '" + path + "'"); + } + if (path.isPrecise()) { + return matches.get(0); + } + else { + return matches; + } + } + + void remove(final FieldPath path, final Map payload) { + traverse(new ProcessingContext(payload, path), new MatchCallback() { + + @Override + public boolean foundMatch(Match match) { + match.remove(); + return true; + } + + @Override + public boolean matchNotFound() { + return true; + } + + }); + } + + private boolean traverse(ProcessingContext context, MatchCallback matchCallback) { + final String segment = context.getSegment(); + if (FieldPath.isArraySegment(segment)) { + if (context.getPayload() instanceof List) { + return handleListPayload(context, matchCallback); + } + } + else if (context.getPayload() instanceof Map + && ((Map) context.getPayload()).containsKey(segment)) { + return handleMapPayload(context, matchCallback); + } + + return matchCallback.matchNotFound(); + } + + private boolean handleListPayload(ProcessingContext context, + MatchCallback matchCallback) { + List list = context.getPayload(); + final Iterator items = list.iterator(); + if (context.isLeaf()) { + while (items.hasNext()) { + Object item = items.next(); + if (!matchCallback.foundMatch(new ListMatch(items, list, item, context + .getParentMatch()))) { + return false; + } + ; + } + return true; + } + else { + boolean result = true; + while (items.hasNext() && result) { + Object item = items.next(); + result = result + && traverse(context.descend(item, new ListMatch(items, list, + item, context.parent)), matchCallback); + } + return result; + } + } + + private boolean handleMapPayload(ProcessingContext context, + MatchCallback matchCallback) { + Map map = context.getPayload(); + final Object item = map.get(context.getSegment()); + MapMatch mapMatch = new MapMatch(item, map, context.getSegment(), + context.getParentMatch()); + if (context.isLeaf()) { + return matchCallback.foundMatch(mapMatch); + } + else { + return traverse(context.descend(item, mapMatch), matchCallback); + } + } + + private final class MapMatch implements Match { + + private final Object item; + + private final Map map; + + private final String segment; + + private final Match parent; + + private MapMatch(Object item, Map map, String segment, Match parent) { + this.item = item; + this.map = map; + this.segment = segment; + this.parent = parent; + } + + @Override + public Object getValue() { + return this.item; + } + + @Override + public void remove() { + this.map.remove(this.segment); + if (this.map.isEmpty() && this.parent != null) { + this.parent.remove(); + } + } + + } + + private final class ListMatch implements Match { + + private final Iterator items; + + private final List list; + + private final Object item; + + private final Match parent; + + private ListMatch(Iterator items, List list, Object item, Match parent) { + this.items = items; + this.list = list; + this.item = item; + this.parent = parent; + } + + @Override + public Object getValue() { + return this.item; + } + + @Override + public void remove() { + this.items.remove(); + if (this.list.isEmpty() && this.parent != null) { + this.parent.remove(); + } + } + + } + + private interface MatchCallback { + + boolean foundMatch(Match match); + + boolean matchNotFound(); + } + + private interface Match { + + Object getValue(); + + void remove(); + } + + private static class ProcessingContext { + + private final Object payload; + + private final List segments; + + private final Match parent; + + private final FieldPath path; + + private ProcessingContext(Object payload, FieldPath path) { + this(payload, path, null, null); + } + + private ProcessingContext(Object payload, FieldPath path, List segments, + Match parent) { + this.payload = payload; + this.path = path; + this.segments = segments == null ? path.getSegments() : segments; + this.parent = parent; + } + + private String getSegment() { + return this.segments.get(0); + } + + @SuppressWarnings("unchecked") + private T getPayload() { + return (T) this.payload; + } + + private boolean isLeaf() { + return this.segments.size() == 1; + } + + private Match getParentMatch() { + return this.parent; + } + + private ProcessingContext descend(Object payload, Match match) { + return new ProcessingContext(payload, this.path, this.segments.subList(1, + this.segments.size()), match); + } + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java index 8444bc3d..7d9bd533 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java @@ -18,7 +18,6 @@ package org.springframework.restdocs.payload; import java.io.IOException; import java.io.Reader; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -46,8 +45,6 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand private final FieldTypeResolver fieldTypeResolver = new FieldTypeResolver(); - private final FieldExtractor fieldExtractor = new FieldExtractor(); - private final FieldValidator fieldValidator = new FieldValidator(); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -73,18 +70,6 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand final Map payload = extractPayload(result); - List missingFields = new ArrayList(); - - for (FieldDescriptor fieldDescriptor : this.fieldDescriptors) { - if (!fieldDescriptor.isOptional()) { - Object field = this.fieldExtractor.extractField( - fieldDescriptor.getPath(), payload); - if (field == null) { - missingFields.add(fieldDescriptor.getPath()); - } - } - } - writer.table(new TableAction() { @Override diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldType.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldType.java index e93f08c1..7eca8684 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldType.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldType.java @@ -27,7 +27,7 @@ import org.springframework.util.StringUtils; */ public enum FieldType { - ARRAY, BOOLEAN, OBJECT, NUMBER, NULL, STRING; + ARRAY, BOOLEAN, OBJECT, NUMBER, NULL, STRING, VARIES; @Override public String toString() { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldTypeResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldTypeResolver.java index 052aa8e7..89d2541b 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldTypeResolver.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldTypeResolver.java @@ -26,10 +26,25 @@ import java.util.Map; */ class FieldTypeResolver { - private final FieldExtractor fieldExtractor = new FieldExtractor(); + private final FieldProcessor fieldProcessor = new FieldProcessor(); FieldType resolveFieldType(String path, Map payload) { - return determineFieldType(this.fieldExtractor.extractField(path, payload)); + FieldPath fieldPath = FieldPath.compile(path); + Object field = this.fieldProcessor.extract(fieldPath, payload); + if (field instanceof Collection && !fieldPath.isPrecise()) { + FieldType commonType = null; + for (Object item : (Collection) field) { + FieldType fieldType = determineFieldType(item); + if (commonType == null) { + commonType = fieldType; + } + else if (fieldType != commonType) { + return FieldType.VARIES; + } + } + return commonType; + } + return determineFieldType(this.fieldProcessor.extract(fieldPath, payload)); } private FieldType determineFieldType(Object fieldValue) { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldValidator.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldValidator.java index 469d0f6c..07583049 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldValidator.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldValidator.java @@ -19,7 +19,6 @@ package org.springframework.restdocs.payload; import java.io.IOException; import java.io.Reader; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Map; @@ -34,7 +33,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; */ class FieldValidator { - private final FieldExtractor fieldExtractor = new FieldExtractor(); + private final FieldProcessor fieldProcessor = new FieldProcessor(); private final ObjectMapper objectMapper = new ObjectMapper() .enable(SerializationFeature.INDENT_OUTPUT); @@ -69,7 +68,8 @@ class FieldValidator { for (FieldDescriptor fieldDescriptor : fieldDescriptors) { if (!fieldDescriptor.isOptional() - && !this.fieldExtractor.hasField(fieldDescriptor.getPath(), payload)) { + && !this.fieldProcessor.hasField( + FieldPath.compile(fieldDescriptor.getPath()), payload)) { missingFields.add(fieldDescriptor.getPath()); } } @@ -80,33 +80,12 @@ class FieldValidator { private Map findUndocumentedFields(Map payload, List fieldDescriptors) { for (FieldDescriptor fieldDescriptor : fieldDescriptors) { - String path = fieldDescriptor.getPath(); - List segments = path.indexOf('.') > -1 ? Arrays.asList(path - .split("\\.")) : Arrays.asList(path); - removeField(segments, 0, payload); + FieldPath path = FieldPath.compile(fieldDescriptor.getPath()); + this.fieldProcessor.remove(path, payload); } return payload; } - @SuppressWarnings({ "unchecked", "rawtypes" }) - private void removeField(List segments, int depth, - Map payloadPortion) { - String key = segments.get(depth); - if (depth == segments.size() - 1) { - payloadPortion.remove(key); - } - else { - Object candidate = payloadPortion.get(key); - if (candidate instanceof Map) { - Map map = (Map) candidate; - removeField(segments, depth + 1, map); - if (map.isEmpty()) { - payloadPortion.remove(key); - } - } - } - } - @SuppressWarnings("serial") static class FieldValidationException extends RuntimeException { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/PayloadDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/PayloadDocumentation.java index 774d23a0..217825ca 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/PayloadDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/PayloadDocumentation.java @@ -35,6 +35,56 @@ public abstract class PayloadDocumentation { /** * Creates a {@code FieldDescriptor} that describes a field with the given * {@code path}. + *

+ * The {@code path} uses '.' to descend into a child object and ' {@code []}' to + * descend into an array. For example, with this JSON payload: + * + *

+	 * {
+     *    "a":{
+     *        "b":[
+     *            {
+     *                "c":"one"
+     *            },
+     *            {
+     *                "c":"two"
+     *            },
+     *            {
+     *                "d":"three"
+     *            }
+     *        ]
+     *    }
+     * }
+	 * 
+ * + * The following paths are all present: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
PathValue
{@code a}An object containing "b"
{@code a.b}An array containing three objects
{@code a.b[]}An array containing three objects
{@code a.b[].c}An array containing the strings "one" and "two"
{@code a.b[].d}The string "three"
* * @param path The path of the field * @return a {@code FieldDescriptor} ready for further configuration diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldPathTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldPathTests.java new file mode 100644 index 00000000..e082aaf1 --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldPathTests.java @@ -0,0 +1,61 @@ +/* + * 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.payload; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Tests for {@link FieldPath} + * + * @author Andy Wilkinson + */ +public class FieldPathTests { + + @Test + public void singleFieldIsPrecise() { + assertTrue(FieldPath.compile("a").isPrecise()); + } + + @Test + public void singleNestedFieldIsPrecise() { + assertTrue(FieldPath.compile("a.b").isPrecise()); + } + + @Test + public void arrayIsNotPrecise() { + assertFalse(FieldPath.compile("a[]").isPrecise()); + } + + @Test + public void nestedArrayIsNotPrecise() { + assertFalse(FieldPath.compile("a.b[]").isPrecise()); + } + + @Test + public void arrayOfArraysIsNotPrecise() { + assertFalse(FieldPath.compile("a[][]").isPrecise()); + } + + @Test + public void fieldBeneathAnArrayIsNotPrecise() { + assertFalse(FieldPath.compile("a[].b").isPrecise()); + } + +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldProcessorTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldProcessorTests.java new file mode 100644 index 00000000..949ae10f --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldProcessorTests.java @@ -0,0 +1,233 @@ +/* + * 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.payload; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.assertThat; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Tests for {@link FieldProcessor} + * + * @author Andy Wilkinson + */ +public class FieldProcessorTests { + + private final FieldProcessor fieldProcessor = new FieldProcessor(); + + @Test + public void extractTopLevelMapEntry() { + Map payload = new HashMap<>(); + payload.put("a", "alpha"); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a"), payload), + equalTo((Object) "alpha")); + } + + @Test + public void extractNestedMapEntry() { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo"); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a.b"), payload), + equalTo((Object) "bravo")); + } + + @Test + public void extractArray() { + Map payload = new HashMap<>(); + Map bravo = new HashMap<>(); + bravo.put("b", "bravo"); + List> alpha = Arrays.asList(bravo, bravo); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a"), payload), + equalTo((Object) alpha)); + } + + @Test + public void extractArrayContents() { + Map payload = new HashMap<>(); + Map bravo = new HashMap<>(); + bravo.put("b", "bravo"); + List> alpha = Arrays.asList(bravo, bravo); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a[]"), payload), + equalTo((Object) alpha)); + } + + @Test + public void extractFromItemsInArray() { + Map payload = new HashMap<>(); + Map entry = new HashMap<>(); + entry.put("b", "bravo"); + List> alpha = Arrays.asList(entry, entry); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a[].b"), payload), + equalTo((Object) Arrays.asList("bravo", "bravo"))); + } + + @Test + public void extractNestedArray() { + Map payload = new HashMap<>(); + Map entry1 = createEntry("id:1"); + Map entry2 = createEntry("id:2"); + Map entry3 = createEntry("id:3"); + List>> alpha = Arrays.asList( + Arrays.asList(entry1, entry2), Arrays.asList(entry3)); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a[][]"), payload), + equalTo((Object) Arrays.asList(entry1, entry2, entry3))); + } + + @Test + public void extractFromItemsInNestedArray() { + Map payload = new HashMap<>(); + Map entry1 = createEntry("id:1"); + Map entry2 = createEntry("id:2"); + Map entry3 = createEntry("id:3"); + List>> alpha = Arrays.asList( + Arrays.asList(entry1, entry2), Arrays.asList(entry3)); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a[][].id"), payload), + equalTo((Object) Arrays.asList("1", "2", "3"))); + } + + @Test + public void extractArraysFromItemsInNestedArray() { + Map payload = new HashMap<>(); + Map entry1 = createEntry("ids", Arrays.asList(1, 2)); + Map entry2 = createEntry("ids", Arrays.asList(3)); + Map entry3 = createEntry("ids", Arrays.asList(4)); + List>> alpha = Arrays.asList( + Arrays.asList(entry1, entry2), Arrays.asList(entry3)); + payload.put("a", alpha); + assertThat(this.fieldProcessor.extract(FieldPath.compile("a[][].ids"), payload), + equalTo((Object) Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3), + Arrays.asList(4)))); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentTopLevelField() { + this.fieldProcessor + .extract(FieldPath.compile("a"), new HashMap()); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentNestedField() { + HashMap payload = new HashMap(); + payload.put("a", new HashMap()); + this.fieldProcessor.extract(FieldPath.compile("a.b"), payload); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentNestedFieldWhenParentIsNotAMap() { + HashMap payload = new HashMap(); + payload.put("a", 5); + this.fieldProcessor.extract(FieldPath.compile("a.b"), payload); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentFieldWhenParentIsAnArray() { + HashMap payload = new HashMap(); + HashMap alpha = new HashMap(); + alpha.put("b", Arrays.asList(new HashMap())); + payload.put("a", alpha); + this.fieldProcessor.extract(FieldPath.compile("a.b.c"), payload); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentArrayField() { + HashMap payload = new HashMap(); + this.fieldProcessor.extract(FieldPath.compile("a[]"), payload); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentArrayFieldAsTypeDoesNotMatch() { + HashMap payload = new HashMap(); + payload.put("a", 5); + this.fieldProcessor.extract(FieldPath.compile("a[]"), payload); + } + + @Test(expected = IllegalArgumentException.class) + public void nonExistentFieldBeneathAnArray() { + HashMap payload = new HashMap(); + HashMap alpha = new HashMap(); + alpha.put("b", Arrays.asList(new HashMap())); + payload.put("a", alpha); + this.fieldProcessor.extract(FieldPath.compile("a.b[].id"), payload); + } + + @Test + public void removeTopLevelMapEntry() { + Map payload = new HashMap<>(); + payload.put("a", "alpha"); + this.fieldProcessor.remove(FieldPath.compile("a"), payload); + assertThat(payload.size(), equalTo(0)); + } + + @Test + public void removeNestedMapEntry() { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo"); + this.fieldProcessor.remove(FieldPath.compile("a.b"), payload); + assertThat(payload.size(), equalTo(0)); + } + + @SuppressWarnings("unchecked") + @Test + public void removeItemsInArray() throws IOException { + Map payload = new ObjectMapper().readValue( + "{\"a\": [{\"b\":\"bravo\"},{\"b\":\"bravo\"}]}", Map.class); + this.fieldProcessor.remove(FieldPath.compile("a[].b"), payload); + assertThat(payload.size(), equalTo(0)); + } + + @SuppressWarnings("unchecked") + @Test + public void removeItemsInNestedArray() throws IOException { + Map payload = new ObjectMapper().readValue( + "{\"a\": [[{\"id\":1},{\"id\":2}], [{\"id\":3}]]}", Map.class); + this.fieldProcessor.remove(FieldPath.compile("a[][].id"), payload); + assertThat(payload.size(), equalTo(0)); + } + + private Map createEntry(String... pairs) { + Map entry = new HashMap<>(); + for (String pair : pairs) { + String[] components = pair.split(":"); + entry.put(components[0], components[1]); + } + return entry; + } + + private Map createEntry(String key, Object value) { + Map entry = new HashMap<>(); + entry.put(key, value); + return entry; + } +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldTypeResolverTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldTypeResolverTests.java index 667225f8..42d5fc9f 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldTypeResolverTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldTypeResolverTests.java @@ -77,6 +77,20 @@ public class FieldTypeResolverTests { createPayload("{\"a\":{\"b\":{\"c\":{}}}}")), equalTo(FieldType.OBJECT)); } + @Test + public void multipleFieldsWithSameType() throws IOException { + assertThat(this.fieldTypeResolver.resolveFieldType("a[].id", + createPayload("{\"a\":[{\"id\":1},{\"id\":2}]}")), + equalTo(FieldType.NUMBER)); + } + + @Test + public void multipleFieldsWithDifferentTypes() throws IOException { + assertThat(this.fieldTypeResolver.resolveFieldType("a[].id", + createPayload("{\"a\":[{\"id\":1},{\"id\":true}]}")), + equalTo(FieldType.VARIES)); + } + @Test public void nonExistentFieldProducesIllegalArgumentException() throws IOException { this.thrownException.expect(IllegalArgumentException.class); diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldValidatorTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldValidatorTests.java index 9af51c0e..e6f2f43f 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldValidatorTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/FieldValidatorTests.java @@ -38,26 +38,28 @@ public class FieldValidatorTests { @Rule public ExpectedException thrownException = ExpectedException.none(); - private StringReader payload = new StringReader("{\"a\":{\"b\":{}, \"c\":true}}"); + private StringReader payload = new StringReader( + "{\"a\":{\"b\":{},\"c\":true,\"d\":[{\"e\":1},{\"e\":2}]}}"); @Test public void noMissingFieldsAllFieldsDocumented() throws IOException { - this.fieldValidator.validate(this.payload, Arrays.asList( - new FieldDescriptor("a"), new FieldDescriptor("a.b"), - new FieldDescriptor("a.c"))); + this.fieldValidator.validate(this.payload, Arrays.asList(new FieldDescriptor( + "a.b"), new FieldDescriptor("a.c"), new FieldDescriptor("a.d[].e"), + new FieldDescriptor("a.d"), new FieldDescriptor("a"))); } @Test public void optionalFieldsAreNotReportedMissing() throws IOException { this.fieldValidator.validate(this.payload, Arrays.asList( new FieldDescriptor("a"), new FieldDescriptor("a.b"), - new FieldDescriptor("a.c"), new FieldDescriptor("y").optional())); + new FieldDescriptor("a.c"), new FieldDescriptor("a.d"), + new FieldDescriptor("y").optional())); } @Test public void parentIsDocumentedWhenAllChildrenAreDocumented() throws IOException { - this.fieldValidator.validate(this.payload, - Arrays.asList(new FieldDescriptor("a.b"), new FieldDescriptor("a.c"))); + this.fieldValidator.validate(this.payload, Arrays.asList(new FieldDescriptor( + "a.b"), new FieldDescriptor("a.c"), new FieldDescriptor("a.d[].e"))); } @Test @@ -83,6 +85,6 @@ public class FieldValidatorTests { .expectMessage(equalTo(String .format("Portions of the payload were not documented:%n{%n \"a\" : {%n \"c\" : true%n }%n}"))); this.fieldValidator.validate(this.payload, - Arrays.asList(new FieldDescriptor("a.b"))); + Arrays.asList(new FieldDescriptor("a.b"), new FieldDescriptor("a.d"))); } } diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/payload/PayloadDocumentationTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/PayloadDocumentationTests.java new file mode 100644 index 00000000..6e45e20f --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/payload/PayloadDocumentationTests.java @@ -0,0 +1,155 @@ +/* + * 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.payload; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertThat; +import static org.springframework.restdocs.payload.PayloadDocumentation.documentRequestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.documentResponseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.hamcrest.Matcher; +import org.hamcrest.collection.IsIterableContainingInAnyOrder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.restdocs.StubMvcResult; +import org.springframework.util.StringUtils; + +/** + * Tests for {@link PayloadDocumentation} + * + * @author Andy Wilkinson + */ +public class PayloadDocumentationTests { + + private final File outputDir = new File("build/payload-documentation-tests"); + + @Before + public void setup() { + System.setProperty("org.springframework.restdocs.outputDir", + this.outputDir.getAbsolutePath()); + } + + @After + public void cleanup() { + System.clearProperty("org.springframework.restdocs.outputDir"); + } + + @Test + public void requestWithFields() throws IOException { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo"); + request.setContent("{\"a\": {\"b\": 5, \"c\": \"charlie\"}}".getBytes()); + documentRequestFields("request-with-fields", + fieldWithPath("a.b").description("one"), + fieldWithPath("a.c").description("two"), + fieldWithPath("a").description("three")).handle( + new StubMvcResult(request, null)); + assertThat( + snippet("request-with-fields", "request-fields"), + is(asciidoctorTableWith(header("Path", "Type", "Description"), + row("a.b", "Number", "one"), row("a.c", "String", "two"), + row("a", "Object", "three")))); + } + + @Test + public void responseWithFields() throws IOException { + MockHttpServletResponse response = new MockHttpServletResponse(); + response.getWriter() + .append("{\"id\": 67,\"date\": \"2015-01-20\",\"assets\": [{\"id\":356,\"name\": \"sample\"}]}"); + documentResponseFields("response-with-fields", + fieldWithPath("id").description("one"), + fieldWithPath("date").description("two"), + fieldWithPath("assets").description("three"), + fieldWithPath("assets[]").description("four"), + fieldWithPath("assets[].id").description("five"), + fieldWithPath("assets[].name").description("six")).handle( + new StubMvcResult(new MockHttpServletRequest("GET", "/"), response)); + assertThat( + snippet("response-with-fields", "response-fields"), + is(asciidoctorTableWith(header("Path", "Type", "Description"), + row("id", "Number", "one"), row("date", "String", "two"), + row("assets", "Array", "three"), + row("assets[]", "Object", "four"), + row("assets[].id", "Number", "five"), + row("assets[].name", "String", "six")))); + } + + private Matcher> asciidoctorTableWith(String[] header, + String[]... rows) { + Collection> matchers = new ArrayList>(); + for (String headerItem : header) { + matchers.add(equalTo(headerItem)); + } + + for (String[] row : rows) { + for (String rowItem : row) { + matchers.add(equalTo(rowItem)); + } + } + + matchers.add(equalTo("|===")); + matchers.add(equalTo("")); + + return new IsIterableContainingInAnyOrder(matchers); + } + + private String[] header(String... columns) { + String header = "|" + + StringUtils.collectionToDelimitedString(Arrays.asList(columns), "|"); + return new String[] { "", "|===", header, "" }; + } + + private String[] row(String... entries) { + List lines = new ArrayList(); + for (String entry : entries) { + lines.add("|" + entry); + } + lines.add(""); + return lines.toArray(new String[lines.size()]); + } + + private List snippet(String snippetName, String snippetType) + throws IOException { + File snippetDir = new File(this.outputDir, snippetName); + File snippetFile = new File(snippetDir, snippetType + ".adoc"); + String line = null; + List lines = new ArrayList(); + BufferedReader reader = new BufferedReader(new FileReader(snippetFile)); + try { + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } + finally { + reader.close(); + } + return lines; + } + +}