Add support for documenting fields in array payloads

764daf7 added support for documenting fields in payloads that contain
arrays, but the array had to be nested within a map. This commit builds
on that support to allow fields in an array payload to be
documented. The fields in the payload:

[
    {
        "a": {
            "b": 5
        }
    },
    {
        "a": {
            "c": "charlie"
        }
    }
]

Can be documented using:

[]a.b
[]a.c
[]a

A dot separator can, optionally, be used between the [] and the map key:

[].a.b
[].a.c
[].a

Closes gh-69
This commit is contained in:
Andy Wilkinson
2015-05-15 21:05:13 +01:00
parent 0c0d3b89bd
commit f98bec317e
8 changed files with 170 additions and 86 deletions

View File

@@ -75,24 +75,32 @@ class FieldPath {
return true;
}
static List<String> extractSegments(String path) {
private static List<String> extractSegments(String path) {
Matcher matcher = ARRAY_INDEX_PATTERN.matcher(path);
String processedPath;
StringBuffer buffer = new StringBuffer();
StringBuilder buffer = new StringBuilder();
int previous = 0;
while (matcher.find()) {
matcher.appendReplacement(buffer, ".[$1]");
appendWithSeparatorIfNecessary(buffer,
path.substring(previous, matcher.start(0)));
appendWithSeparatorIfNecessary(buffer, matcher.group());
previous = matcher.end(0);
}
if (previous < path.length()) {
appendWithSeparatorIfNecessary(buffer, path.substring(previous));
}
matcher.appendTail(buffer);
if (buffer.length() > 0) {
processedPath = buffer.toString();
}
else {
processedPath = path;
}
String processedPath = buffer.toString();
return Arrays.asList(processedPath.indexOf('.') > -1 ? processedPath.split("\\.")
: new String[] { processedPath });
}
private static void appendWithSeparatorIfNecessary(StringBuilder buffer,
String toAppend) {
if (buffer.length() > 0 && (buffer.lastIndexOf(".") != buffer.length() - 1)
&& !toAppend.startsWith(".")) {
buffer.append(".");
}
buffer.append(toAppend);
}
}

View File

@@ -31,38 +31,26 @@ import java.util.concurrent.atomic.AtomicReference;
*/
class FieldProcessor {
boolean hasField(FieldPath fieldPath, Map<String, Object> payload) {
boolean hasField(FieldPath fieldPath, Object payload) {
final AtomicReference<Boolean> hasField = new AtomicReference<Boolean>(false);
traverse(new ProcessingContext(payload, fieldPath), new MatchCallback() {
@Override
public boolean foundMatch(Match match) {
public void 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) {
Object extract(FieldPath path, Object payload) {
final List<Object> matches = new ArrayList<Object>();
traverse(new ProcessingContext(payload, path), new MatchCallback() {
@Override
public boolean foundMatch(Match match) {
public void foundMatch(Match match) {
matches.add(match.getValue());
return true;
}
@Override
public boolean matchNotFound() {
return false;
}
});
@@ -78,75 +66,59 @@ class FieldProcessor {
}
}
void remove(final FieldPath path, final Map<String, Object> payload) {
void remove(final FieldPath path, Object payload) {
traverse(new ProcessingContext(payload, path), new MatchCallback() {
@Override
public boolean foundMatch(Match match) {
public void foundMatch(Match match) {
match.remove();
return true;
}
@Override
public boolean matchNotFound() {
return true;
}
});
}
private boolean traverse(ProcessingContext context, MatchCallback matchCallback) {
private void traverse(ProcessingContext context, MatchCallback matchCallback) {
final String segment = context.getSegment();
if (FieldPath.isArraySegment(segment)) {
if (context.getPayload() instanceof List) {
return handleListPayload(context, matchCallback);
handleListPayload(context, matchCallback);
}
}
else if (context.getPayload() instanceof Map
&& ((Map<?, ?>) context.getPayload()).containsKey(segment)) {
return handleMapPayload(context, matchCallback);
handleMapPayload(context, matchCallback);
}
return matchCallback.matchNotFound();
}
private boolean handleListPayload(ProcessingContext context,
MatchCallback matchCallback) {
private void 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;
}
matchCallback.foundMatch(new ListMatch(items, list, item, context
.getParentMatch()));
}
return true;
}
else {
boolean result = true;
while (items.hasNext() && result) {
while (items.hasNext()) {
Object item = items.next();
result = result
&& traverse(context.descend(item, new ListMatch(items, list,
item, context.parent)), matchCallback);
traverse(context.descend(item, new ListMatch(items, list, item,
context.parent)), matchCallback);
}
return result;
}
}
private boolean handleMapPayload(ProcessingContext context,
MatchCallback matchCallback) {
private void handleMapPayload(ProcessingContext context, MatchCallback matchCallback) {
Map<?, ?> map = context.getPayload();
final Object item = map.get(context.getSegment());
Object item = map.get(context.getSegment());
MapMatch mapMatch = new MapMatch(item, map, context.getSegment(),
context.getParentMatch());
if (context.isLeaf()) {
return matchCallback.foundMatch(mapMatch);
matchCallback.foundMatch(mapMatch);
}
else {
return traverse(context.descend(item, mapMatch), matchCallback);
traverse(context.descend(item, mapMatch), matchCallback);
}
}
@@ -216,9 +188,8 @@ class FieldProcessor {
private interface MatchCallback {
boolean foundMatch(Match match);
void foundMatch(Match match);
boolean matchNotFound();
}
private interface Match {

View File

@@ -68,7 +68,7 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand
this.fieldValidator.validate(getPayloadReader(result), this.fieldDescriptors);
final Map<String, Object> payload = extractPayload(result);
final Object payload = extractPayload(result);
writer.table(new TableAction() {
@@ -91,15 +91,8 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand
}
@SuppressWarnings("unchecked")
private Map<String, Object> extractPayload(MvcResult result) throws IOException {
Reader payloadReader = getPayloadReader(result);
try {
return this.objectMapper.readValue(payloadReader, Map.class);
}
finally {
payloadReader.close();
}
private Object extractPayload(MvcResult result) throws IOException {
return this.objectMapper.readValue(getPayloadReader(result), Object.class);
}
protected abstract Reader getPayloadReader(MvcResult result) throws IOException;

View File

@@ -28,7 +28,7 @@ class FieldTypeResolver {
private final FieldProcessor fieldProcessor = new FieldProcessor();
FieldType resolveFieldType(String path, Map<String, Object> payload) {
FieldType resolveFieldType(String path, Object payload) {
FieldPath fieldPath = FieldPath.compile(path);
Object field = this.fieldProcessor.extract(fieldPath, payload);
if (field instanceof Collection && !fieldPath.isPrecise()) {

View File

@@ -40,18 +40,15 @@ class FieldValidator {
private final ObjectMapper objectMapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
@SuppressWarnings("unchecked")
void validate(Reader payloadReader, List<FieldDescriptor> fieldDescriptors)
throws IOException {
Map<String, Object> payload = this.objectMapper.readValue(payloadReader,
Map.class);
Object payload = this.objectMapper.readValue(payloadReader, Object.class);
List<String> missingFields = findMissingFields(payload, fieldDescriptors);
Map<String, Object> undocumentedPayload = findUndocumentedFields(payload,
fieldDescriptors);
Object undocumentedPayload = findUndocumentedFields(payload, fieldDescriptors);
if (!missingFields.isEmpty() || !undocumentedPayload.isEmpty()) {
if (!missingFields.isEmpty() || !isEmpty(undocumentedPayload)) {
String message = "";
if (!undocumentedPayload.isEmpty()) {
if (!isEmpty(undocumentedPayload)) {
message += String.format(
"The following parts of the payload were not documented:%n%s",
this.objectMapper.writeValueAsString(undocumentedPayload));
@@ -67,7 +64,14 @@ class FieldValidator {
}
}
private List<String> findMissingFields(Map<String, Object> payload,
private boolean isEmpty(Object object) {
if (object instanceof Map) {
return ((Map<?, ?>) object).isEmpty();
}
return ((List<?>) object).isEmpty();
}
private List<String> findMissingFields(Object payload,
List<FieldDescriptor> fieldDescriptors) {
List<String> missingFields = new ArrayList<String>();
@@ -82,7 +86,7 @@ class FieldValidator {
return missingFields;
}
private Map<String, Object> findUndocumentedFields(Map<String, Object> payload,
private Object findUndocumentedFields(Object payload,
List<FieldDescriptor> fieldDescriptors) {
for (FieldDescriptor fieldDescriptor : fieldDescriptors) {
FieldPath path = FieldPath.compile(fieldDescriptor.getPath());

View File

@@ -16,7 +16,9 @@
package org.springframework.restdocs.payload;
import static org.hamcrest.Matchers.contains;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
@@ -38,6 +40,16 @@ public class FieldPathTests {
assertTrue(FieldPath.compile("a.b").isPrecise());
}
@Test
public void topLevelArrayIsNotPrecise() {
assertFalse(FieldPath.compile("[]").isPrecise());
}
@Test
public void fieldBeneathTopLevelArrayIsNotPrecise() {
assertFalse(FieldPath.compile("[]a").isPrecise());
}
@Test
public void arrayIsNotPrecise() {
assertFalse(FieldPath.compile("a[]").isPrecise());
@@ -58,4 +70,44 @@ public class FieldPathTests {
assertFalse(FieldPath.compile("a[].b").isPrecise());
}
@Test
public void compilationOfSingleElementPath() {
assertThat(FieldPath.compile("a").getSegments(), contains("a"));
}
@Test
public void compilationOfMultipleElementPath() {
assertThat(FieldPath.compile("a.b.c").getSegments(), contains("a", "b", "c"));
}
@Test
public void compilationOfPathWithArraysWithNoDotSeparators() {
assertThat(FieldPath.compile("a[]b[]c").getSegments(),
contains("a", "[]", "b", "[]", "c"));
}
@Test
public void compilationOfPathWithArraysWithPreAndPostDotSeparators() {
assertThat(FieldPath.compile("a.[].b.[].c").getSegments(),
contains("a", "[]", "b", "[]", "c"));
}
@Test
public void compilationOfPathWithArraysWithPreDotSeparators() {
assertThat(FieldPath.compile("a.[]b.[]c").getSegments(),
contains("a", "[]", "b", "[]", "c"));
}
@Test
public void compilationOfPathWithArraysWithPostDotSeparators() {
assertThat(FieldPath.compile("a[].b[].c").getSegments(),
contains("a", "[]", "b", "[]", "c"));
}
@Test
public void compilationOfPathStartingWithAnArray() {
assertThat(FieldPath.compile("[]a.b.c").getSegments(),
contains("[]", "a", "b", "c"));
}
}

View File

@@ -38,6 +38,9 @@ public class FieldValidatorTests {
@Rule
public ExpectedException thrownException = ExpectedException.none();
private StringReader listPayload = new StringReader(
"[{\"a\":1},{\"a\":2},{\"b\":{\"c\":3}}]");
private StringReader payload = new StringReader(
"{\"a\":{\"b\":{},\"c\":true,\"d\":[{\"e\":1},{\"e\":2}]}}");
@@ -88,4 +91,24 @@ public class FieldValidatorTests {
this.fieldValidator.validate(this.payload,
Arrays.asList(new FieldDescriptor("a.b"), new FieldDescriptor("a.d")));
}
@Test
public void listPayloadNoMissingFieldsAllFieldsDocumented() throws IOException {
this.fieldValidator.validate(this.listPayload, Arrays.asList(new FieldDescriptor(
"[]b.c"), new FieldDescriptor("[]b"), new FieldDescriptor("[]a"),
new FieldDescriptor("[]")));
}
@Test
public void listPayloadParentIsDocumentedWhenAllChildrenAreDocumented()
throws IOException {
this.fieldValidator.validate(this.listPayload,
Arrays.asList(new FieldDescriptor("[]b.c"), new FieldDescriptor("[]a")));
}
@Test
public void listPayloadChildIsDocumentedWhenParentIsDocumented() throws IOException {
this.fieldValidator.validate(this.listPayload,
Arrays.asList(new FieldDescriptor("[]")));
}
}

View File

@@ -48,14 +48,14 @@ public class PayloadDocumentationTests {
public final ExpectedSnippet snippet = new ExpectedSnippet();
@Test
public void requestWithFields() throws IOException {
this.snippet.expectRequestFields("request-with-fields").withContents( //
public void mapRequestWithFields() throws IOException {
this.snippet.expectRequestFields("map-request-with-fields").withContents( //
tableWithHeader("Path", "Type", "Description") //
.row("a.b", "Number", "one") //
.row("a.c", "String", "two") //
.row("a", "Object", "three"));
documentRequestFields("request-with-fields",
documentRequestFields("map-request-with-fields",
fieldWithPath("a.b").description("one"),
fieldWithPath("a.c").description("two"),
fieldWithPath("a").description("three")).handle(
@@ -63,8 +63,24 @@ public class PayloadDocumentationTests {
}
@Test
public void responseWithFields() throws IOException {
this.snippet.expectResponseFields("response-with-fields").withContents(//
public void arrayRequestWithFields() throws IOException {
this.snippet.expectRequestFields("array-request-with-fields").withContents( //
tableWithHeader("Path", "Type", "Description") //
.row("[]a.b", "Number", "one") //
.row("[]a.c", "String", "two") //
.row("[]a", "Object", "three"));
documentRequestFields("array-request-with-fields",
fieldWithPath("[]a.b").description("one"),
fieldWithPath("[]a.c").description("two"),
fieldWithPath("[]a").description("three")).handle(
result(get("/foo").content(
"[{\"a\": {\"b\": 5}},{\"a\": {\"c\": \"charlie\"}}]")));
}
@Test
public void mapResponseWithFields() throws IOException {
this.snippet.expectResponseFields("map-response-with-fields").withContents(//
tableWithHeader("Path", "Type", "Description") //
.row("id", "Number", "one") //
.row("date", "String", "two") //
@@ -77,7 +93,7 @@ public class PayloadDocumentationTests {
response.getWriter().append(
"{\"id\": 67,\"date\": \"2015-01-20\",\"assets\":"
+ " [{\"id\":356,\"name\": \"sample\"}]}");
documentResponseFields("response-with-fields",
documentResponseFields("map-response-with-fields",
fieldWithPath("id").description("one"),
fieldWithPath("date").description("two"),
fieldWithPath("assets").description("three"),
@@ -87,6 +103,23 @@ public class PayloadDocumentationTests {
result(response));
}
@Test
public void arrayResponseWithFields() throws IOException {
this.snippet.expectResponseFields("array-response-with-fields").withContents( //
tableWithHeader("Path", "Type", "Description") //
.row("[]a.b", "Number", "one") //
.row("[]a.c", "String", "two") //
.row("[]a", "Object", "three"));
MockHttpServletResponse response = new MockHttpServletResponse();
response.getWriter()
.append("[{\"a\": {\"b\": 5}},{\"a\": {\"c\": \"charlie\"}}]");
documentResponseFields("array-response-with-fields",
fieldWithPath("[]a.b").description("one"),
fieldWithPath("[]a.c").description("two"),
fieldWithPath("[]a").description("three")).handle(result(response));
}
@Test
public void undocumentedRequestField() throws IOException {
this.thrown.expect(SnippetGenerationException.class);