Skip to content

Commit 9e1ba8a

Browse files
committed
Improve beneathPath to work with multiple matches with common structure
Closes gh-473
1 parent 5b49397 commit 9e1ba8a

File tree

5 files changed

+260
-28
lines changed

5 files changed

+260
-28
lines changed

spring-restdocs-core/src/main/java/org/springframework/restdocs/payload/FieldPathPayloadSubsectionExtractor.java

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717
package org.springframework.restdocs.payload;
1818

1919
import java.io.IOException;
20+
import java.util.ArrayList;
2021
import java.util.List;
22+
import java.util.Set;
2123

2224
import com.fasterxml.jackson.databind.ObjectMapper;
2325

2426
import org.springframework.http.MediaType;
25-
import org.springframework.restdocs.payload.JsonFieldPath.PathType;
2627
import org.springframework.restdocs.payload.JsonFieldProcessor.ExtractedField;
2728

2829
/**
@@ -44,7 +45,7 @@ public class FieldPathPayloadSubsectionExtractor
4445

4546
/**
4647
* Creates a new {@code FieldPathPayloadSubsectionExtractor} that will extract the
47-
* subsection of the JSON payload found at the given {@code fieldPath}. The
48+
* subsection of the JSON payload beneath the given {@code fieldPath}. The
4849
* {@code fieldPath} prefixed with {@code beneath-} with be used as the subsection ID.
4950
* @param fieldPath the path of the field
5051
*/
@@ -54,8 +55,8 @@ protected FieldPathPayloadSubsectionExtractor(String fieldPath) {
5455

5556
/**
5657
* Creates a new {@code FieldPathPayloadSubsectionExtractor} that will extract the
57-
* subsection of the JSON payload found at the given {@code fieldPath} and that will
58-
* us the given {@code subsectionId} to identify the subsection.
58+
* subsection of the JSON payload beneath the given {@code fieldPath} and that will
59+
* use the given {@code subsectionId} to identify the subsection.
5960
* @param fieldPath the path of the field
6061
* @param subsectionId the ID of the subsection
6162
*/
@@ -70,14 +71,23 @@ public byte[] extractSubsection(byte[] payload, MediaType contentType) {
7071
ExtractedField extractedField = new JsonFieldProcessor().extract(
7172
this.fieldPath, objectMapper.readValue(payload, Object.class));
7273
Object value = extractedField.getValue();
73-
if (value instanceof List && extractedField.getType() == PathType.MULTI) {
74+
if (value instanceof List) {
7475
List<?> extractedList = (List<?>) value;
75-
if (extractedList.size() == 1) {
76+
Set<String> uncommonPaths = JsonFieldPaths.from(extractedList)
77+
.getUncommon();
78+
if (uncommonPaths.isEmpty()) {
7679
value = extractedList.get(0);
7780
}
7881
else {
79-
throw new PayloadHandlingException(this.fieldPath
80-
+ " does not uniquely identify a subsection of the payload");
82+
String message = this.fieldPath + " identifies multiple sections of "
83+
+ "the payload and they do not have a common structure. The "
84+
+ "following uncommon paths were found: ";
85+
List<String> prefixedPaths = new ArrayList<>();
86+
for (String uncommonPath : uncommonPaths) {
87+
prefixedPaths.add(this.fieldPath + "." + uncommonPath);
88+
}
89+
message += prefixedPaths;
90+
throw new PayloadHandlingException(message);
8191
}
8292
}
8393
return objectMapper.writeValueAsBytes(value);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2014-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.restdocs.payload;
18+
19+
import java.util.Collection;
20+
import java.util.HashSet;
21+
import java.util.LinkedHashSet;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Map.Entry;
25+
import java.util.Set;
26+
27+
/**
28+
* {@code JsonFieldPaths} provides support for extracting fields paths from JSON
29+
* structures and identifying uncommon paths.
30+
*
31+
* @author Andy Wilkinson
32+
*/
33+
final class JsonFieldPaths {
34+
35+
private final Set<String> uncommonFieldPaths;
36+
37+
private JsonFieldPaths(Set<String> uncommonFieldPaths) {
38+
this.uncommonFieldPaths = uncommonFieldPaths;
39+
}
40+
41+
Set<String> getUncommon() {
42+
return this.uncommonFieldPaths;
43+
}
44+
45+
static JsonFieldPaths from(Collection<?> items) {
46+
Set<Set<String>> itemsFieldPaths = new HashSet<>();
47+
Set<String> allFieldPaths = new HashSet<>();
48+
for (Object item : items) {
49+
Set<String> paths = new LinkedHashSet<>();
50+
from(paths, "", item);
51+
itemsFieldPaths.add(paths);
52+
allFieldPaths.addAll(paths);
53+
}
54+
Set<String> uncommonFieldPaths = new HashSet<>();
55+
for (Set<String> itemFieldPaths : itemsFieldPaths) {
56+
Set<String> uncommonForItem = new HashSet<>(allFieldPaths);
57+
uncommonForItem.removeAll(itemFieldPaths);
58+
uncommonFieldPaths.addAll(uncommonForItem);
59+
}
60+
return new JsonFieldPaths(uncommonFieldPaths);
61+
}
62+
63+
private static void from(Set<String> paths, String parent, Object object) {
64+
if (object instanceof List) {
65+
String path = append(parent, "[]");
66+
paths.add(path);
67+
from(paths, path, (List<?>) object);
68+
}
69+
else if (object instanceof Map) {
70+
from(paths, parent, (Map<?, ?>) object);
71+
}
72+
}
73+
74+
private static void from(Set<String> paths, String parent, List<?> items) {
75+
for (Object item : items) {
76+
from(paths, parent, item);
77+
}
78+
}
79+
80+
private static void from(Set<String> paths, String parent, Map<?, ?> map) {
81+
for (Entry<?, ?> entry : map.entrySet()) {
82+
String path = append(parent, entry.getKey());
83+
paths.add(path);
84+
from(paths, path, entry.getValue());
85+
}
86+
}
87+
88+
private static String append(String path, Object suffix) {
89+
return (path.length() == 0) ? ("" + suffix) : (path + "." + suffix);
90+
}
91+
92+
}

spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/FieldPathPayloadSubsectionExtractorTests.java

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.springframework.restdocs.payload;
1818

1919
import java.io.IOException;
20-
import java.util.List;
2120
import java.util.Map;
2221

2322
import com.fasterxml.jackson.core.JsonParseException;
@@ -30,7 +29,6 @@
3029
import org.springframework.http.MediaType;
3130

3231
import static org.assertj.core.api.Assertions.assertThat;
33-
import static org.hamcrest.CoreMatchers.equalTo;
3432

3533
/**
3634
* Tests for {@link FieldPathPayloadSubsectionExtractor}.
@@ -57,29 +55,28 @@ public void extractMapSubsectionOfJsonMap()
5755

5856
@Test
5957
@SuppressWarnings("unchecked")
60-
public void extractMultiElementArraySubsectionOfJsonMap()
58+
public void extractSingleElementArraySubsectionOfJsonMap()
6159
throws JsonParseException, JsonMappingException, IOException {
62-
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a")
63-
.extractSubsection("{\"a\":[{\"b\":5},{\"b\":4}]}".getBytes(),
60+
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a.[]")
61+
.extractSubsection("{\"a\":[{\"b\":5}]}".getBytes(),
6462
MediaType.APPLICATION_JSON);
65-
List<Map<String, Object>> extracted = new ObjectMapper()
66-
.readValue(extractedPayload, List.class);
67-
assertThat(extracted.size()).isEqualTo(2);
68-
assertThat(extracted.get(0).get("b")).isEqualTo(5);
69-
assertThat(extracted.get(1).get("b")).isEqualTo(4);
63+
Map<String, Object> extracted = new ObjectMapper().readValue(extractedPayload,
64+
Map.class);
65+
assertThat(extracted.size()).isEqualTo(1);
66+
assertThat(extracted).containsOnlyKeys("b");
7067
}
7168

7269
@Test
7370
@SuppressWarnings("unchecked")
74-
public void extractSingleElementArraySubsectionOfJsonMap()
71+
public void extractMultiElementArraySubsectionOfJsonMap()
7572
throws JsonParseException, JsonMappingException, IOException {
76-
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a.[]")
77-
.extractSubsection("{\"a\":[{\"b\":5}]}".getBytes(),
73+
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a")
74+
.extractSubsection("{\"a\":[{\"b\":5},{\"b\":4}]}".getBytes(),
7875
MediaType.APPLICATION_JSON);
79-
List<Map<String, Object>> extracted = new ObjectMapper()
80-
.readValue(extractedPayload, List.class);
76+
Map<String, Object> extracted = new ObjectMapper().readValue(extractedPayload,
77+
Map.class);
8178
assertThat(extracted.size()).isEqualTo(1);
82-
assertThat(extracted.get(0).get("b")).isEqualTo(5);
79+
assertThat(extracted).containsOnlyKeys("b");
8380
}
8481

8582
@Test
@@ -96,13 +93,26 @@ public void extractMapSubsectionFromSingleElementArrayInAJsonMap()
9693
}
9794

9895
@Test
99-
public void extractMapSubsectionFromMultiElementArrayInAJsonMap()
96+
@SuppressWarnings("unchecked")
97+
public void extractMapSubsectionWithCommonStructureFromMultiElementArrayInAJsonMap()
98+
throws JsonParseException, JsonMappingException, IOException {
99+
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a.[].b")
100+
.extractSubsection(
101+
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6}}]}".getBytes(),
102+
MediaType.APPLICATION_JSON);
103+
Map<String, Object> extracted = new ObjectMapper().readValue(extractedPayload,
104+
Map.class);
105+
assertThat(extracted.size()).isEqualTo(1);
106+
assertThat(extracted).containsOnlyKeys("c");
107+
}
108+
109+
@Test
110+
public void extractMapSubsectionWithVaryingStructureFromMultiElementArrayInAJsonMap()
100111
throws JsonParseException, JsonMappingException, IOException {
101112
this.thrown.expect(PayloadHandlingException.class);
102-
this.thrown.expectMessage(
103-
equalTo("a.[].b does not uniquely identify a subsection of the payload"));
113+
this.thrown.expectMessage("The following uncommon paths were found: [a.[].b.d]");
104114
new FieldPathPayloadSubsectionExtractor("a.[].b").extractSubsection(
105-
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6}}]}".getBytes(),
115+
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6, \"d\": 7}}]}".getBytes(),
106116
MediaType.APPLICATION_JSON);
107117
}
108118

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2014-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.restdocs.payload;
18+
19+
import java.io.IOException;
20+
import java.util.Arrays;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import org.junit.Test;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
27+
/**
28+
* Tests for {@link JsonFieldPaths}.
29+
*
30+
* @author Andy Wilkinson
31+
*/
32+
public class JsonFieldPathsTests {
33+
34+
@Test
35+
public void noUncommonPathsForSingleItem() {
36+
assertThat(JsonFieldPaths
37+
.from(Arrays
38+
.asList(json("{\"a\": 1, \"b\": [ { \"c\": 2}, {\"c\": 3} ]}")))
39+
.getUncommon()).isEmpty();
40+
}
41+
42+
@Test
43+
public void noUncommonPathsForMultipleIdenticalItems() {
44+
Object item = json("{\"a\": 1, \"b\": [ { \"c\": 2}, {\"c\": 3} ]}");
45+
assertThat(JsonFieldPaths.from(Arrays.asList(item, item)).getUncommon())
46+
.isEmpty();
47+
}
48+
49+
@Test
50+
public void noUncommonPathsForMultipleMatchingItemsWithDifferentScalarValues() {
51+
assertThat(JsonFieldPaths
52+
.from(Arrays.asList(
53+
json("{\"a\": 1, \"b\": [ { \"c\": 2}, {\"c\": 3} ]}"),
54+
json("{\"a\": 4, \"b\": [ { \"c\": 5}, {\"c\": 6} ]}")))
55+
.getUncommon()).isEmpty();
56+
}
57+
58+
@Test
59+
public void missingEntryInMapIsIdentifiedAsUncommon() {
60+
assertThat(JsonFieldPaths.from(Arrays.asList(json("{\"a\": 1}"),
61+
json("{\"a\": 1}"), json("{\"a\": 1, \"b\": 2}"))).getUncommon())
62+
.containsExactly("b");
63+
}
64+
65+
@Test
66+
public void missingEntryInNestedMapIsIdentifiedAsUncommon() {
67+
assertThat(
68+
JsonFieldPaths
69+
.from(Arrays.asList(json("{\"a\": 1, \"b\": {\"c\": 1}}"),
70+
json("{\"a\": 1, \"b\": {\"c\": 1}}"),
71+
json("{\"a\": 1, \"b\": {\"c\": 1, \"d\": 2}}")))
72+
.getUncommon()).containsExactly("b.d");
73+
}
74+
75+
@Test
76+
public void missingEntriesInNestedMapAreIdentifiedAsUncommon() {
77+
assertThat(
78+
JsonFieldPaths.from(Arrays.asList(json("{\"a\": 1, \"b\": {\"c\": 1}}"),
79+
json("{\"a\": 1, \"b\": {\"c\": 1}}"),
80+
json("{\"a\": 1, \"b\": {\"d\": 2}}"))).getUncommon())
81+
.containsExactly("b.c", "b.d");
82+
}
83+
84+
@Test
85+
public void missingEntryBeneathArrayIsIdentifiedAsUncommon() {
86+
assertThat(JsonFieldPaths.from(Arrays.asList(json("[{\"b\": 1}]"),
87+
json("[{\"b\": 1}]"), json("[{\"b\": 1, \"c\": 2}]"))).getUncommon())
88+
.containsExactly("[].c");
89+
}
90+
91+
@Test
92+
public void missingEntryBeneathNestedArrayIsIdentifiedAsUncommon() {
93+
assertThat(JsonFieldPaths.from(Arrays.asList(json("{\"a\": [{\"b\": 1}]}"),
94+
json("{\"a\": [{\"b\": 1}]}"), json("{\"a\": [{\"b\": 1, \"c\": 2}]}")))
95+
.getUncommon()).containsExactly("a.[].c");
96+
}
97+
98+
private Object json(String json) {
99+
try {
100+
return new ObjectMapper().readValue(json, Object.class);
101+
}
102+
catch (IOException ex) {
103+
throw new RuntimeException(ex);
104+
}
105+
}
106+
107+
}

spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/ResponseFieldsSnippetTests.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ public void subsectionOfMapResponse() throws IOException {
9494
.row("`b`", "`Number`", "one").row("`c`", "`String`", "two"));
9595
}
9696

97+
@Test
98+
public void subsectionOfMapResponseBeneathAnArray() throws IOException {
99+
responseFields(beneathPath("a.b.[]"), fieldWithPath("c").description("one"),
100+
fieldWithPath("d.[].e").description("two"))
101+
.document(this.operationBuilder.response().content(
102+
"{\"a\": {\"b\": [{\"c\": 1, \"d\": [{\"e\": 5}]}, {\"c\": 3, \"d\": [{\"e\": 4}]}]}}")
103+
.build());
104+
assertThat(this.generatedSnippets.snippet("response-fields-beneath-a.b.[]"))
105+
.is(tableWithHeader("Path", "Type", "Description")
106+
.row("`c`", "`Number`", "one")
107+
.row("`d.[].e`", "`Number`", "two"));
108+
}
109+
97110
@Test
98111
public void subsectionOfMapResponseWithCommonsPrefix() throws IOException {
99112
responseFields(beneathPath("a"))

0 commit comments

Comments
 (0)