Add support for documenting fields in payloads that contain arrays

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
This commit is contained in:
Andy Wilkinson
2015-04-27 16:43:50 +01:00
parent 8166c5e3e7
commit 764daf7c99
14 changed files with 924 additions and 104 deletions

View File

@@ -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 {

View File

@@ -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<String, Object> 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<String, Object> 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;
}
}

View File

@@ -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<String> segments;
private final boolean precise;
private FieldPath(String rawPath, List<String> segments, boolean precise) {
this.rawPath = rawPath;
this.segments = segments;
this.precise = precise;
}
boolean isPrecise() {
return this.precise;
}
List<String> getSegments() {
return this.segments;
}
@Override
public String toString() {
return this.rawPath;
}
static FieldPath compile(String path) {
List<String> 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<String> segments) {
for (String segment : segments) {
if (isArraySegment(segment)) {
return false;
}
}
return true;
}
static List<String> 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 });
}
}

View File

@@ -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<String, Object> payload) {
final AtomicReference<Boolean> hasField = new AtomicReference<Boolean>(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<String, Object> payload) {
final List<Object> matches = new ArrayList<Object>();
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<String, Object> 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<String> 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<String> 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> 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);
}
}
}

View File

@@ -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<String, Object> payload = extractPayload(result);
List<String> missingFields = new ArrayList<String>();
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

View File

@@ -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() {

View File

@@ -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<String, Object> 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) {

View File

@@ -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<String, Object> findUndocumentedFields(Map<String, Object> payload,
List<FieldDescriptor> fieldDescriptors) {
for (FieldDescriptor fieldDescriptor : fieldDescriptors) {
String path = fieldDescriptor.getPath();
List<String> 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<String> segments, int depth,
Map<String, Object> 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 {

View File

@@ -35,6 +35,56 @@ public abstract class PayloadDocumentation {
/**
* Creates a {@code FieldDescriptor} that describes a field with the given
* {@code path}.
* <p>
* The {@code path} uses '.' to descend into a child object and ' {@code []}' to
* descend into an array. For example, with this JSON payload:
*
* <pre>
* {
* "a":{
* "b":[
* {
* "c":"one"
* },
* {
* "c":"two"
* },
* {
* "d":"three"
* }
* ]
* }
* }
* </pre>
*
* The following paths are all present:
*
* <table summary="Paths and their values">
* <tr>
* <th>Path</th>
* <th>Value</th>
* </tr>
* <tr>
* <td>{@code a}</td>
* <td>An object containing "b"</td>
* </tr>
* <tr>
* <td>{@code a.b}</td>
* <td>An array containing three objects</td>
* </tr>
* <tr>
* <td>{@code a.b[]}</td>
* <td>An array containing three objects</td>
* </tr>
* <tr>
* <td>{@code a.b[].c}</td>
* <td>An array containing the strings "one" and "two"</td>
* </tr>
* <tr>
* <td>{@code a.b[].d}</td>
* <td>The string "three"</td>
* </tr>
* </table>
*
* @param path The path of the field
* @return a {@code FieldDescriptor} ready for further configuration

View File

@@ -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());
}
}

View File

@@ -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<String, Object> payload = new HashMap<>();
payload.put("a", "alpha");
assertThat(this.fieldProcessor.extract(FieldPath.compile("a"), payload),
equalTo((Object) "alpha"));
}
@Test
public void extractNestedMapEntry() {
Map<String, Object> payload = new HashMap<>();
Map<String, Object> 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<String, Object> payload = new HashMap<>();
Map<String, Object> bravo = new HashMap<>();
bravo.put("b", "bravo");
List<Map<String, Object>> 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<String, Object> payload = new HashMap<>();
Map<String, Object> bravo = new HashMap<>();
bravo.put("b", "bravo");
List<Map<String, Object>> 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<String, Object> payload = new HashMap<>();
Map<String, Object> entry = new HashMap<>();
entry.put("b", "bravo");
List<Map<String, Object>> 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<String, Object> payload = new HashMap<>();
Map<String, String> entry1 = createEntry("id:1");
Map<String, String> entry2 = createEntry("id:2");
Map<String, String> entry3 = createEntry("id:3");
List<List<Map<String, String>>> 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<String, Object> payload = new HashMap<>();
Map<String, String> entry1 = createEntry("id:1");
Map<String, String> entry2 = createEntry("id:2");
Map<String, String> entry3 = createEntry("id:3");
List<List<Map<String, String>>> 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<String, Object> payload = new HashMap<>();
Map<String, Object> entry1 = createEntry("ids", Arrays.asList(1, 2));
Map<String, Object> entry2 = createEntry("ids", Arrays.asList(3));
Map<String, Object> entry3 = createEntry("ids", Arrays.asList(4));
List<List<Map<String, Object>>> 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<String, Object>());
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentNestedField() {
HashMap<String, Object> payload = new HashMap<String, Object>();
payload.put("a", new HashMap<String, Object>());
this.fieldProcessor.extract(FieldPath.compile("a.b"), payload);
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentNestedFieldWhenParentIsNotAMap() {
HashMap<String, Object> payload = new HashMap<String, Object>();
payload.put("a", 5);
this.fieldProcessor.extract(FieldPath.compile("a.b"), payload);
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentFieldWhenParentIsAnArray() {
HashMap<String, Object> payload = new HashMap<String, Object>();
HashMap<String, Object> alpha = new HashMap<String, Object>();
alpha.put("b", Arrays.asList(new HashMap<String, Object>()));
payload.put("a", alpha);
this.fieldProcessor.extract(FieldPath.compile("a.b.c"), payload);
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentArrayField() {
HashMap<String, Object> payload = new HashMap<String, Object>();
this.fieldProcessor.extract(FieldPath.compile("a[]"), payload);
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentArrayFieldAsTypeDoesNotMatch() {
HashMap<String, Object> payload = new HashMap<String, Object>();
payload.put("a", 5);
this.fieldProcessor.extract(FieldPath.compile("a[]"), payload);
}
@Test(expected = IllegalArgumentException.class)
public void nonExistentFieldBeneathAnArray() {
HashMap<String, Object> payload = new HashMap<String, Object>();
HashMap<String, Object> alpha = new HashMap<String, Object>();
alpha.put("b", Arrays.asList(new HashMap<String, Object>()));
payload.put("a", alpha);
this.fieldProcessor.extract(FieldPath.compile("a.b[].id"), payload);
}
@Test
public void removeTopLevelMapEntry() {
Map<String, Object> payload = new HashMap<>();
payload.put("a", "alpha");
this.fieldProcessor.remove(FieldPath.compile("a"), payload);
assertThat(payload.size(), equalTo(0));
}
@Test
public void removeNestedMapEntry() {
Map<String, Object> payload = new HashMap<>();
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, String> createEntry(String... pairs) {
Map<String, String> entry = new HashMap<>();
for (String pair : pairs) {
String[] components = pair.split(":");
entry.put(components[0], components[1]);
}
return entry;
}
private Map<String, Object> createEntry(String key, Object value) {
Map<String, Object> entry = new HashMap<>();
entry.put(key, value);
return entry;
}
}

View File

@@ -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);

View File

@@ -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")));
}
}

View File

@@ -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<Iterable<? extends String>> asciidoctorTableWith(String[] header,
String[]... rows) {
Collection<Matcher<? super String>> matchers = new ArrayList<Matcher<? super String>>();
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<String>(matchers);
}
private String[] header(String... columns) {
String header = "|"
+ StringUtils.collectionToDelimitedString(Arrays.asList(columns), "|");
return new String[] { "", "|===", header, "" };
}
private String[] row(String... entries) {
List<String> lines = new ArrayList<String>();
for (String entry : entries) {
lines.add("|" + entry);
}
lines.add("");
return lines.toArray(new String[lines.size()]);
}
private List<String> 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<String> lines = new ArrayList<String>();
BufferedReader reader = new BufferedReader(new FileReader(snippetFile));
try {
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
finally {
reader.close();
}
return lines;
}
}