From ab1613ed6390a37a9829983fae94d2912b8411ee Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 23 Mar 2017 11:05:48 +0000 Subject: [PATCH] Add support for using a wildcard when documenting a payload field Closes gh-265 --- .../docs/asciidoc/documenting-your-api.adoc | 16 ++- .../restdocs/payload/JsonFieldPath.java | 10 +- .../restdocs/payload/JsonFieldProcessor.java | 77 +++++++------ .../restdocs/payload/JsonFieldPathTests.java | 26 +++++ .../payload/JsonFieldProcessorTests.java | 101 ++++++++++++++++++ .../payload/RequestFieldsSnippetTests.java | 26 ++++- .../payload/ResponseFieldsSnippetTests.java | 28 ++++- 7 files changed, 238 insertions(+), 46 deletions(-) diff --git a/docs/src/docs/asciidoc/documenting-your-api.adoc b/docs/src/docs/asciidoc/documenting-your-api.adoc index eab21d72..c016f960 100644 --- a/docs/src/docs/asciidoc/documenting-your-api.adoc +++ b/docs/src/docs/asciidoc/documenting-your-api.adoc @@ -282,7 +282,6 @@ The following paths are all present: |`['a']['e.dot']` |The string `four` - |=== A payload that uses an array at its root can also be documented. The path `[]` will refer @@ -302,7 +301,22 @@ found in the following array: ] ---- +You can use `\*` as a wildcard to match fields with different names. For example, +`users.*.role` could be used to document the role of every user in the following JSON: +[source,json.indent=0] +---- + { + "users":{ + "ab12cd34":{ + "role": "Administrator" + }, + "12ab34cd":{ + "role": "Guest" + } + } + } +---- [[documenting-your-api-request-response-payloads-fields-json-field-types]] ====== JSON field types diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldPath.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldPath.java index 03ddfefb..35da9d65 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldPath.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldPath.java @@ -72,8 +72,9 @@ final class JsonFieldPath { static JsonFieldPath compile(String path) { List segments = extractSegments(path); + String leafSegment = segments.get(segments.size() - 1); return new JsonFieldPath(path, segments, matchesSingleValue(segments), - isArraySegment(segments.get(segments.size() - 1))); + isArraySegment(leafSegment) || isWildcardSegment(leafSegment)); } static boolean isArraySegment(String segment) { @@ -83,13 +84,18 @@ final class JsonFieldPath { static boolean matchesSingleValue(List segments) { Iterator iterator = segments.iterator(); while (iterator.hasNext()) { - if (isArraySegment(iterator.next()) && iterator.hasNext()) { + String next = iterator.next(); + if ((isArraySegment(next) || isWildcardSegment(next)) && iterator.hasNext()) { return false; } } return true; } + private static boolean isWildcardSegment(String segment) { + return "*".equals(segment); + } + private static List extractSegments(String path) { Matcher matcher = BRACKETS_AND_ARRAY_PATTERN.matcher(path); diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldProcessor.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldProcessor.java index dfbd5ff4..d74fd4e2 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldProcessor.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/JsonFieldProcessor.java @@ -17,6 +17,7 @@ package org.springframework.restdocs.payload; import java.util.ArrayList; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -88,36 +89,38 @@ final class JsonFieldProcessor { } private void traverse(ProcessingContext context, MatchCallback matchCallback) { - final String segment = context.getSegment(); + String segment = context.getSegment(); if (JsonFieldPath.isArraySegment(segment)) { - if (context.getPayload() instanceof List) { - handleListPayload(context, matchCallback); + if (context.getPayload() instanceof Collection) { + handleCollectionPayload(context, matchCallback); } } - else if (context.getPayload() instanceof Map - && ((Map) context.getPayload()).containsKey(segment)) { + else if (context.getPayload() instanceof Map) { handleMapPayload(context, matchCallback); } } - private void handleListPayload(ProcessingContext context, + private void handleCollectionPayload(ProcessingContext context, MatchCallback matchCallback) { - List list = context.getPayload(); - final Iterator items = list.iterator(); + handleCollectionPayload((Collection) context.getPayload(), matchCallback, + context); + } + + private void handleCollectionPayload(Collection collection, + MatchCallback matchCallback, ProcessingContext context) { + Iterator items = collection.iterator(); if (context.isLeaf()) { while (items.hasNext()) { Object item = items.next(); - matchCallback.foundMatch( - new ListMatch(items, list, item, context.getParentMatch())); + matchCallback.foundMatch(new CollectionMatch(items, collection, item, + context.getParentMatch())); } } else { while (items.hasNext()) { Object item = items.next(); - traverse( - context.descend(item, - new ListMatch(items, list, item, context.parent)), - matchCallback); + traverse(context.descend(item, new CollectionMatch(items, collection, + item, context.getParentMatch())), matchCallback); } } } @@ -126,13 +129,18 @@ final class JsonFieldProcessor { MatchCallback matchCallback) { Map map = context.getPayload(); Object item = map.get(context.getSegment()); - MapMatch mapMatch = new MapMatch(item, map, context.getSegment(), - context.getParentMatch()); - if (context.isLeaf()) { - matchCallback.foundMatch(mapMatch); + if (item != null || map.containsKey(context.getSegment())) { + MapMatch mapMatch = new MapMatch(item, map, context.getSegment(), + context.getParentMatch()); + if (context.isLeaf()) { + matchCallback.foundMatch(mapMatch); + } + else { + traverse(context.descend(item, mapMatch), matchCallback); + } } - else { - traverse(context.descend(item, mapMatch), matchCallback); + else if ("*".equals(context.getSegment())) { + handleCollectionPayload(map.values(), matchCallback, context); } } @@ -162,7 +170,7 @@ final class JsonFieldProcessor { public void remove() { Object removalCandidate = this.map.get(this.segment); if (isMapWithEntries(removalCandidate) - || isListWithNonScalarEntries(removalCandidate)) { + || isCollectionWithNonScalarEntries(removalCandidate)) { return; } this.map.remove(this.segment); @@ -183,12 +191,12 @@ final class JsonFieldProcessor { return object instanceof Map && !((Map) object).isEmpty(); } - private boolean isListWithNonScalarEntries(Object object) { - if (!(object instanceof List)) { + private boolean isCollectionWithNonScalarEntries(Object object) { + if (!(object instanceof Collection)) { return false; } - for (Object entry : (List) object) { - if (entry instanceof Map || entry instanceof List) { + for (Object entry : (Collection) object) { + if (entry instanceof Map || entry instanceof Collection) { return true; } } @@ -197,19 +205,20 @@ final class JsonFieldProcessor { } - private static final class ListMatch implements Match { + private static final class CollectionMatch implements Match { private final Iterator items; - private final List list; + private final Collection collection; private final Object item; private final Match parent; - private ListMatch(Iterator items, List list, Object item, Match parent) { + private CollectionMatch(Iterator items, Collection collection, Object item, + Match parent) { this.items = items; - this.list = list; + this.collection = collection; this.item = item; this.parent = parent; } @@ -225,7 +234,7 @@ final class JsonFieldProcessor { return; } this.items.remove(); - if (this.list.isEmpty() && this.parent != null) { + if (this.collection.isEmpty() && this.parent != null) { this.parent.remove(); } } @@ -233,21 +242,21 @@ final class JsonFieldProcessor { @Override public void removeSubsection() { this.items.remove(); - if (this.list.isEmpty() && this.parent != null) { + if (this.collection.isEmpty() && this.parent != null) { this.parent.removeSubsection(); } } private boolean itemIsEmpty() { - return !isMapWithEntries(this.item) && !isListWithEntries(this.item); + return !isMapWithEntries(this.item) && !isCollectionWithEntries(this.item); } private boolean isMapWithEntries(Object object) { return object instanceof Map && !((Map) object).isEmpty(); } - private boolean isListWithEntries(Object object) { - return object instanceof List && !((List) object).isEmpty(); + private boolean isCollectionWithEntries(Object object) { + return object instanceof Collection && !((Collection) object).isEmpty(); } } diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldPathTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldPathTests.java index dc71f15e..2d97fd5f 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldPathTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldPathTests.java @@ -145,4 +145,30 @@ public class JsonFieldPathTests { contains("a.key", "[]", "b", "c")); } + @Test + public void compilationOfPathWithAWildcard() { + assertThat(JsonFieldPath.compile("a.b.*.c").getSegments(), + contains("a", "b", "*", "c")); + } + + @Test + public void compilationOfPathWithAWildcardInBrackets() { + assertThat(JsonFieldPath.compile("a.b.['*'].c").getSegments(), + contains("a", "b", "*", "c")); + } + + @Test + public void fieldBeneathTopLevelWildcardIsNotPreciseAndNotAnArray() { + JsonFieldPath path = JsonFieldPath.compile("*.a"); + assertFalse(path.isPrecise()); + assertFalse(path.isArray()); + } + + @Test + public void fieldBeneathNestedWildcardIsNotPreciseAndNotAnArray() { + JsonFieldPath path = JsonFieldPath.compile("a.*.b"); + assertFalse(path.isPrecise()); + assertFalse(path.isArray()); + } + } diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldProcessorTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldProcessorTests.java index 145f6121..fa8d01d9 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldProcessorTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonFieldProcessorTests.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -27,6 +28,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Test; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasKey; import static org.junit.Assert.assertThat; /** @@ -305,6 +310,102 @@ public class JsonFieldProcessorTests { equalTo((Object) "bravo")); } + @SuppressWarnings("unchecked") + @Test + public void extractNestedEntriesUsingTopLevelWildcard() throws IOException { + Map payload = new LinkedHashMap<>(); + Map alpha = new LinkedHashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo1"); + Map charlie = new LinkedHashMap<>(); + charlie.put("b", "bravo2"); + payload.put("c", charlie); + assertThat((List) this.fieldProcessor + .extract(JsonFieldPath.compile("*.b"), payload), + contains("bravo1", "bravo2")); + } + + @SuppressWarnings("unchecked") + @Test + public void extractNestedEntriesUsingMidLevelWildcard() throws IOException { + Map payload = new LinkedHashMap<>(); + Map alpha = new LinkedHashMap<>(); + payload.put("a", alpha); + Map bravo = new LinkedHashMap<>(); + bravo.put("b", "bravo"); + alpha.put("one", bravo); + alpha.put("two", bravo); + assertThat((List) this.fieldProcessor + .extract(JsonFieldPath.compile("a.*.b"), payload), + contains("bravo", "bravo")); + } + + @SuppressWarnings("unchecked") + @Test + public void extractUsingLeafWildcardMatchingSingleItem() throws IOException { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo1"); + Map charlie = new HashMap<>(); + charlie.put("b", "bravo2"); + payload.put("c", charlie); + assertThat((List) this.fieldProcessor + .extract(JsonFieldPath.compile("a.*"), payload), contains("bravo1")); + } + + @SuppressWarnings("unchecked") + @Test + public void extractUsingLeafWildcardMatchingMultipleItems() throws IOException { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo1"); + alpha.put("c", "charlie"); + assertThat((List) this.fieldProcessor + .extract(JsonFieldPath.compile("a.*"), payload), + contains("bravo1", "charlie")); + } + + @Test + public void removeUsingLeafWildcard() throws IOException { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo1"); + alpha.put("c", "charlie"); + this.fieldProcessor.remove(JsonFieldPath.compile("a.*"), payload); + assertThat(payload.size(), equalTo(0)); + } + + @Test + public void removeUsingTopLevelWildcard() throws IOException { + Map payload = new HashMap<>(); + Map alpha = new HashMap<>(); + payload.put("a", alpha); + alpha.put("b", "bravo1"); + alpha.put("c", "charlie"); + this.fieldProcessor.remove(JsonFieldPath.compile("*.b"), payload); + assertThat(alpha, not(hasKey("b"))); + } + + @Test + public void removeUsingMidLevelWildcard() throws IOException { + Map payload = new LinkedHashMap<>(); + Map alpha = new LinkedHashMap<>(); + payload.put("a", alpha); + payload.put("c", "charlie"); + Map bravo1 = new LinkedHashMap<>(); + bravo1.put("b", "bravo"); + alpha.put("one", bravo1); + Map bravo2 = new LinkedHashMap<>(); + bravo2.put("b", "bravo"); + alpha.put("two", bravo2); + this.fieldProcessor.remove(JsonFieldPath.compile("a.*.b"), payload); + assertThat(payload.size(), equalTo(1)); + assertThat(payload, hasEntry("c", (Object) "charlie")); + } + private Map createEntry(String... pairs) { Map entry = new HashMap<>(); for (String pair : pairs) { diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/RequestFieldsSnippetTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/RequestFieldsSnippetTests.java index 9ad807f7..aa351b40 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/RequestFieldsSnippetTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/RequestFieldsSnippetTests.java @@ -107,7 +107,7 @@ public class RequestFieldsSnippetTests extends AbstractSnippetTests { .document(this.operationBuilder.request("http://localhost") .content( "[{\"a\": {\"b\": 5}},{\"a\": {\"c\": \"charlie\"}}]") - .build()); + .build()); } @Test @@ -267,8 +267,8 @@ public class RequestFieldsSnippetTests extends AbstractSnippetTests { public void xmlRequestFields() throws IOException { this.snippets.expectRequestFields() .withContents(tableWithHeader("Path", "Type", "Description") - .row("`a/b`", "`b`", "one").row("`a/c`", "`c`", "two").row("`a`", - "`a`", "three")); + .row("`a/b`", "`b`", "one").row("`a/c`", "`c`", "two") + .row("`a`", "`a`", "three")); new RequestFieldsSnippet( Arrays.asList(fieldWithPath("a/b").description("one").type("b"), @@ -279,7 +279,7 @@ public class RequestFieldsSnippetTests extends AbstractSnippetTests { .content("5charlie") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_XML_VALUE) - .build()); + .build()); } @Test @@ -338,6 +338,24 @@ public class RequestFieldsSnippetTests extends AbstractSnippetTests { .content("{\"Foo|Bar\": 5}").build()); } + @Test + public void mapRequestWithVaryingKeysMatchedUsingWildcard() throws IOException { + this.snippets.expectRequestFields() + .withContents(tableWithHeader("Path", "Type", "Description") + .row("`things.*.size`", "`String`", "one") + .row("`things.*.type`", "`String`", "two")); + + new RequestFieldsSnippet( + Arrays.asList(fieldWithPath("things.*.size").description("one"), + fieldWithPath("things.*.type").description("two"))).document( + this.operationBuilder.request("http://localhost") + .content("{\"things\": {\"12abf\": {\"type\":" + + "\"Whale\", \"size\": \"HUGE\"}," + + "\"gzM33\" : {\"type\": \"Screw\"," + + "\"size\": \"SMALL\"}}}") + .build()); + } + private String escapeIfNecessary(String input) { if (this.templateFormat.equals(TemplateFormats.markdown())) { return input; diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/ResponseFieldsSnippetTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/ResponseFieldsSnippetTests.java index 3b8c1728..5b3438fb 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/ResponseFieldsSnippetTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/ResponseFieldsSnippetTests.java @@ -241,8 +241,8 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests { public void xmlResponseFields() throws IOException { this.snippets.expectResponseFields() .withContents(tableWithHeader("Path", "Type", "Description") - .row("`a/b`", "`b`", "one").row("`a/c`", "`c`", "two").row("`a`", - "`a`", "three")); + .row("`a/b`", "`b`", "one").row("`a/c`", "`c`", "two") + .row("`a`", "`a`", "three")); new ResponseFieldsSnippet( Arrays.asList(fieldWithPath("a/b").description("one").type("b"), fieldWithPath("a/c").description("two").type("c"), @@ -252,7 +252,7 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests { .content("5charlie") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_XML_VALUE) - .build()); + .build()); } @Test @@ -268,7 +268,7 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests { .content("foo") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_XML_VALUE) - .build()); + .build()); } @Test @@ -284,7 +284,7 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests { .content("foo") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_XML_VALUE) - .build()); + .build()); } @Test @@ -348,6 +348,24 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests { .content("{\"Foo|Bar\": 5}").build()); } + @Test + public void mapResponseWithVaryingKeysMatchedUsingWildcard() throws IOException { + this.snippets.expectResponseFields() + .withContents(tableWithHeader("Path", "Type", "Description") + .row("`things.*.size`", "`String`", "one") + .row("`things.*.type`", "`String`", "two")); + + new ResponseFieldsSnippet( + Arrays.asList(fieldWithPath("things.*.size").description("one"), + fieldWithPath("things.*.type").description("two"))) + .document(this.operationBuilder.response() + .content("{\"things\": {\"12abf\": {\"type\":" + + "\"Whale\", \"size\": \"HUGE\"}," + + "\"gzM33\" : {\"type\": \"Screw\"," + + "\"size\": \"SMALL\"}}}") + .build()); + } + private String escapeIfNecessary(String input) { if (this.templateFormat.equals(TemplateFormats.markdown())) { return input;