Add support for using a wildcard when documenting a payload field
Closes gh-265
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -72,8 +72,9 @@ final class JsonFieldPath {
|
||||
|
||||
static JsonFieldPath compile(String path) {
|
||||
List<String> 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<String> segments) {
|
||||
Iterator<String> 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<String> extractSegments(String path) {
|
||||
Matcher matcher = BRACKETS_AND_ARRAY_PATTERN.matcher(path);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String, Object> payload = new LinkedHashMap<>();
|
||||
Map<String, Object> alpha = new LinkedHashMap<>();
|
||||
payload.put("a", alpha);
|
||||
alpha.put("b", "bravo1");
|
||||
Map<String, Object> charlie = new LinkedHashMap<>();
|
||||
charlie.put("b", "bravo2");
|
||||
payload.put("c", charlie);
|
||||
assertThat((List<String>) this.fieldProcessor
|
||||
.extract(JsonFieldPath.compile("*.b"), payload),
|
||||
contains("bravo1", "bravo2"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
public void extractNestedEntriesUsingMidLevelWildcard() throws IOException {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
Map<String, Object> alpha = new LinkedHashMap<>();
|
||||
payload.put("a", alpha);
|
||||
Map<String, Object> bravo = new LinkedHashMap<>();
|
||||
bravo.put("b", "bravo");
|
||||
alpha.put("one", bravo);
|
||||
alpha.put("two", bravo);
|
||||
assertThat((List<String>) this.fieldProcessor
|
||||
.extract(JsonFieldPath.compile("a.*.b"), payload),
|
||||
contains("bravo", "bravo"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
public void extractUsingLeafWildcardMatchingSingleItem() throws IOException {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
Map<String, Object> alpha = new HashMap<>();
|
||||
payload.put("a", alpha);
|
||||
alpha.put("b", "bravo1");
|
||||
Map<String, Object> charlie = new HashMap<>();
|
||||
charlie.put("b", "bravo2");
|
||||
payload.put("c", charlie);
|
||||
assertThat((List<String>) this.fieldProcessor
|
||||
.extract(JsonFieldPath.compile("a.*"), payload), contains("bravo1"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
public void extractUsingLeafWildcardMatchingMultipleItems() throws IOException {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
Map<String, Object> alpha = new HashMap<>();
|
||||
payload.put("a", alpha);
|
||||
alpha.put("b", "bravo1");
|
||||
alpha.put("c", "charlie");
|
||||
assertThat((List<String>) this.fieldProcessor
|
||||
.extract(JsonFieldPath.compile("a.*"), payload),
|
||||
contains("bravo1", "charlie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removeUsingLeafWildcard() throws IOException {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> payload = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> payload = new LinkedHashMap<>();
|
||||
Map<String, Object> alpha = new LinkedHashMap<>();
|
||||
payload.put("a", alpha);
|
||||
payload.put("c", "charlie");
|
||||
Map<String, Object> bravo1 = new LinkedHashMap<>();
|
||||
bravo1.put("b", "bravo");
|
||||
alpha.put("one", bravo1);
|
||||
Map<String, Object> 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<String, String> createEntry(String... pairs) {
|
||||
Map<String, String> entry = new HashMap<>();
|
||||
for (String pair : pairs) {
|
||||
|
||||
@@ -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("<a><b>5</b><c>charlie</c></a>")
|
||||
.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;
|
||||
|
||||
@@ -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("<a><b>5</b><c>charlie</c></a>")
|
||||
.header(HttpHeaders.CONTENT_TYPE,
|
||||
MediaType.APPLICATION_XML_VALUE)
|
||||
.build());
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -268,7 +268,7 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests {
|
||||
.content("<a id=\"1\">foo</a>")
|
||||
.header(HttpHeaders.CONTENT_TYPE,
|
||||
MediaType.APPLICATION_XML_VALUE)
|
||||
.build());
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -284,7 +284,7 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests {
|
||||
.content("<a>foo</a>")
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user