Skip to content

Commit a38a512

Browse files
committed
Require occasionally null or absent field in array to be marked optional
Closes gh-402
1 parent a84bb0c commit a38a512

File tree

6 files changed

+321
-24
lines changed

6 files changed

+321
-24
lines changed

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

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2014-2016 the original author or authors.
2+
* Copyright 2014-2017 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,7 +21,6 @@
2121
import java.util.Iterator;
2222
import java.util.List;
2323
import java.util.Map;
24-
import java.util.concurrent.atomic.AtomicReference;
2524

2625
/**
2726
* A {@code JsonFieldProcessor} processes a payload's fields, allowing them to be
@@ -32,17 +31,10 @@
3231
*/
3332
final class JsonFieldProcessor {
3433

35-
boolean hasField(JsonFieldPath fieldPath, Object payload) {
36-
final AtomicReference<Boolean> hasField = new AtomicReference<>(false);
37-
traverse(new ProcessingContext(payload, fieldPath), new MatchCallback() {
38-
39-
@Override
40-
public void foundMatch(Match match) {
41-
hasField.set(true);
42-
}
43-
44-
});
45-
return hasField.get();
34+
boolean hasField(final JsonFieldPath fieldPath, Object payload) {
35+
HasFieldMatchCallback callback = new HasFieldMatchCallback();
36+
traverse(new ProcessingContext(payload, fieldPath), callback);
37+
return callback.fieldFound();
4638
}
4739

4840
Object extract(JsonFieldPath path, Object payload) {
@@ -54,6 +46,11 @@ public void foundMatch(Match match) {
5446
matches.add(match.getValue());
5547
}
5648

49+
@Override
50+
public void absent() {
51+
52+
}
53+
5754
});
5855
if (matches.isEmpty()) {
5956
throw new FieldDoesNotExistException(path);
@@ -74,6 +71,11 @@ public void foundMatch(Match match) {
7471
match.remove();
7572
}
7673

74+
@Override
75+
public void absent() {
76+
77+
}
78+
7779
});
7880
}
7981

@@ -85,6 +87,11 @@ public void foundMatch(Match match) {
8587
match.removeSubsection();
8688
}
8789

90+
@Override
91+
public void absent() {
92+
93+
}
94+
8895
});
8996
}
9097

@@ -128,8 +135,8 @@ private void handleCollectionPayload(Collection<?> collection,
128135
private void handleMapPayload(ProcessingContext context,
129136
MatchCallback matchCallback) {
130137
Map<?, ?> map = context.getPayload();
131-
Object item = map.get(context.getSegment());
132-
if (item != null || map.containsKey(context.getSegment())) {
138+
if (map.containsKey(context.getSegment())) {
139+
Object item = map.get(context.getSegment());
133140
MapMatch mapMatch = new MapMatch(item, map, context.getSegment(),
134141
context.getParentMatch());
135142
if (context.isLeaf()) {
@@ -142,6 +149,47 @@ private void handleMapPayload(ProcessingContext context,
142149
else if ("*".equals(context.getSegment())) {
143150
handleCollectionPayload(map.values(), matchCallback, context);
144151
}
152+
else {
153+
matchCallback.absent();
154+
}
155+
}
156+
157+
/**
158+
* {@link MatchCallback} use to determine whether a payload has a particular field.
159+
*/
160+
private static final class HasFieldMatchCallback implements MatchCallback {
161+
162+
private MatchType matchType = MatchType.NONE;
163+
164+
@Override
165+
public void foundMatch(Match match) {
166+
this.matchType = this.matchType.combinedWith(
167+
match.getValue() == null ? MatchType.NULL : MatchType.NON_NULL);
168+
}
169+
170+
@Override
171+
public void absent() {
172+
this.matchType = this.matchType.combinedWith(MatchType.ABSENT);
173+
}
174+
175+
boolean fieldFound() {
176+
return this.matchType == MatchType.NON_NULL
177+
|| this.matchType == MatchType.NULL;
178+
}
179+
180+
private static enum MatchType {
181+
182+
ABSENT, MIXED, NONE, NULL, NON_NULL;
183+
184+
MatchType combinedWith(MatchType matchType) {
185+
if (this == NONE || this == matchType) {
186+
return matchType;
187+
}
188+
return MIXED;
189+
}
190+
191+
}
192+
145193
}
146194

147195
private static final class MapMatch implements Match {
@@ -265,6 +313,8 @@ private interface MatchCallback {
265313

266314
void foundMatch(Match match);
267315

316+
void absent();
317+
268318
}
269319

270320
private interface Match {

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

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2014-2016 the original author or authors.
2+
* Copyright 2014-2017 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@
2828
import org.junit.Test;
2929

3030
import static org.hamcrest.CoreMatchers.equalTo;
31+
import static org.hamcrest.CoreMatchers.is;
3132
import static org.hamcrest.CoreMatchers.not;
3233
import static org.hamcrest.Matchers.contains;
3334
import static org.hamcrest.Matchers.hasEntry;
@@ -105,6 +106,31 @@ public void extractFromItemsInArray() {
105106
equalTo((Object) Arrays.asList("bravo", "bravo")));
106107
}
107108

109+
@Test
110+
public void extractOccasionallyAbsentFieldFromItemsInArray() {
111+
Map<String, Object> payload = new HashMap<>();
112+
Map<String, Object> entry = new HashMap<>();
113+
entry.put("b", "bravo");
114+
List<Map<String, Object>> alpha = Arrays.asList(entry,
115+
new HashMap<String, Object>());
116+
payload.put("a", alpha);
117+
assertThat(this.fieldProcessor.extract(JsonFieldPath.compile("a[].b"), payload),
118+
equalTo((Object) Arrays.asList("bravo")));
119+
}
120+
121+
@Test
122+
public void extractOccasionallyNullFieldFromItemsInArray() {
123+
Map<String, Object> payload = new HashMap<>();
124+
Map<String, Object> nonNullField = new HashMap<>();
125+
nonNullField.put("b", "bravo");
126+
Map<String, Object> nullField = new HashMap<>();
127+
nullField.put("b", null);
128+
List<Map<String, Object>> alpha = Arrays.asList(nonNullField, nullField);
129+
payload.put("a", alpha);
130+
assertThat(this.fieldProcessor.extract(JsonFieldPath.compile("a[].b"), payload),
131+
equalTo((Object) Arrays.asList("bravo", null)));
132+
}
133+
108134
@Test
109135
public void extractNestedArray() {
110136
Map<String, Object> payload = new HashMap<>();
@@ -406,6 +432,82 @@ public void removeUsingMidLevelWildcard() throws IOException {
406432
assertThat(payload, hasEntry("c", (Object) "charlie"));
407433
}
408434

435+
@Test
436+
public void hasFieldIsTrueForNonNullFieldInMap() throws Exception {
437+
Map<String, Object> payload = new HashMap<>();
438+
payload.put("a", "alpha");
439+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a"), payload),
440+
is(true));
441+
}
442+
443+
@Test
444+
public void hasFieldIsTrueForNullFieldInMap() throws Exception {
445+
Map<String, Object> payload = new HashMap<>();
446+
payload.put("a", null);
447+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a"), payload),
448+
is(true));
449+
}
450+
451+
@Test
452+
public void hasFieldIsFalseForAbsentFieldInMap() throws Exception {
453+
Map<String, Object> payload = new HashMap<>();
454+
payload.put("a", null);
455+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("b"), payload),
456+
is(false));
457+
}
458+
459+
@Test
460+
public void hasFieldIsTrueForNeverNullFieldBeneathArray() throws Exception {
461+
Map<String, Object> payload = new HashMap<>();
462+
Map<String, Object> nested = new HashMap<>();
463+
nested.put("b", "bravo");
464+
payload.put("a", Arrays.asList(nested, nested, nested));
465+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a.[].b"), payload),
466+
is(true));
467+
}
468+
469+
@Test
470+
public void hasFieldIsTrueForAlwaysNullFieldBeneathArray() throws Exception {
471+
Map<String, Object> payload = new HashMap<>();
472+
Map<String, Object> nested = new HashMap<>();
473+
nested.put("b", null);
474+
payload.put("a", Arrays.asList(nested, nested, nested));
475+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a.[].b"), payload),
476+
is(true));
477+
}
478+
479+
@Test
480+
public void hasFieldIsFalseForAlwaysAbsentFieldBeneathArray() throws Exception {
481+
Map<String, Object> payload = new HashMap<>();
482+
Map<String, Object> nested = new HashMap<>();
483+
nested.put("b", "bravo");
484+
payload.put("a", Arrays.asList(nested, nested, nested));
485+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a.[].c"), payload),
486+
is(false));
487+
}
488+
489+
@Test
490+
public void hasFieldIsFalseForOccasionallyAbsentFieldBeneathArray() throws Exception {
491+
Map<String, Object> payload = new HashMap<>();
492+
Map<String, Object> nested = new HashMap<>();
493+
nested.put("b", "bravo");
494+
payload.put("a", Arrays.asList(nested, new HashMap<>(), nested));
495+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a.[].b"), payload),
496+
is(false));
497+
}
498+
499+
@Test
500+
public void hasFieldIsFalseForOccasionallyNullFieldBeneathArray() throws Exception {
501+
Map<String, Object> payload = new HashMap<>();
502+
Map<String, Object> fieldPresent = new HashMap<>();
503+
fieldPresent.put("b", "bravo");
504+
Map<String, Object> fieldNull = new HashMap<>();
505+
fieldNull.put("b", null);
506+
payload.put("a", Arrays.asList(fieldPresent, fieldPresent, fieldNull));
507+
assertThat(this.fieldProcessor.hasField(JsonFieldPath.compile("a.[].b"), payload),
508+
is(false));
509+
}
510+
409511
private Map<String, String> createEntry(String... pairs) {
410512
Map<String, String> entry = new HashMap<>();
411513
for (String pair : pairs) {

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ public void missingXmlRequestField() throws IOException {
180180
public void undocumentedXmlRequestFieldAndMissingXmlRequestField()
181181
throws IOException {
182182
this.thrown.expect(SnippetException.class);
183-
this.thrown.expectMessage(startsWith(
184-
"The following parts of the payload were not" + " documented:"));
183+
this.thrown.expectMessage(
184+
startsWith("The following parts of the payload were not documented:"));
185185
this.thrown
186186
.expectMessage(endsWith("Fields with the following paths were not found"
187187
+ " in the payload: [a/b]"));
@@ -204,4 +204,36 @@ public void unsupportedContent() throws IOException {
204204
.build());
205205
}
206206

207+
@Test
208+
public void nonOptionalFieldBeneathArrayThatIsSometimesNull() throws IOException {
209+
this.thrown.expect(SnippetException.class);
210+
this.thrown.expectMessage(startsWith(
211+
"Fields with the following paths were not found in the payload: "
212+
+ "[a[].b]"));
213+
new RequestFieldsSnippet(Arrays.asList(
214+
fieldWithPath("a[].b").description("one").type(JsonFieldType.NUMBER),
215+
fieldWithPath("a[].c").description("two").type(JsonFieldType.NUMBER)))
216+
.document(this.operationBuilder.request("http://localhost")
217+
.content("{\"a\":[{\"b\": 1,\"c\": 2}, "
218+
+ "{\"b\": null, \"c\": 2},"
219+
+ " {\"b\": 1,\"c\": 2}]}")
220+
.build());
221+
}
222+
223+
@Test
224+
public void nonOptionalFieldBeneathArrayThatIsSometimesAbsent() throws IOException {
225+
this.thrown.expect(SnippetException.class);
226+
this.thrown.expectMessage(startsWith(
227+
"Fields with the following paths were not found in the payload: "
228+
+ "[a[].b]"));
229+
new RequestFieldsSnippet(Arrays.asList(
230+
fieldWithPath("a[].b").description("one").type(JsonFieldType.NUMBER),
231+
fieldWithPath("a[].c").description("two").type(JsonFieldType.NUMBER)))
232+
.document(
233+
this.operationBuilder.request("http://localhost")
234+
.content("{\"a\":[{\"b\": 1,\"c\": 2}, "
235+
+ "{\"c\": 2}, {\"b\": 1,\"c\": 2}]}")
236+
.build());
237+
}
238+
207239
}

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ public void mapRequestWithFields() throws IOException {
6666
.build());
6767
}
6868

69+
@Test
70+
public void mapRequestWithNullField() throws IOException {
71+
this.snippets.expectRequestFields()
72+
.withContents(tableWithHeader("Path", "Type", "Description").row("`a.b`",
73+
"`Null`", "one"));
74+
75+
new RequestFieldsSnippet(Arrays.asList(fieldWithPath("a.b").description("one")))
76+
.document(this.operationBuilder.request("http://localhost")
77+
.content("{\"a\": {\"b\": null}}").build());
78+
}
79+
6980
@Test
7081
public void entireSubsectionsCanBeDocumented() throws IOException {
7182
this.snippets.expectRequestFields()
@@ -105,8 +116,20 @@ public void arrayRequestWithFields() throws IOException {
105116
fieldWithPath("[]a.c").description("three"),
106117
fieldWithPath("[]a").description("four")))
107118
.document(this.operationBuilder.request("http://localhost")
108-
.content(
109-
"[{\"a\": {\"b\": 5}},{\"a\": {\"c\": \"charlie\"}}]")
119+
.content("[{\"a\": {\"b\": 5, \"c\":\"charlie\"}},"
120+
+ "{\"a\": {\"b\": 4, \"c\":\"chalk\"}}]")
121+
.build());
122+
}
123+
124+
@Test
125+
public void arrayRequestWithAlwaysNullField() throws IOException {
126+
this.snippets.expectRequestFields()
127+
.withContents(tableWithHeader("Path", "Type", "Description")
128+
.row("`[]a.b`", "`Null`", "one"));
129+
130+
new RequestFieldsSnippet(Arrays.asList(fieldWithPath("[]a.b").description("one")))
131+
.document(this.operationBuilder.request("http://localhost")
132+
.content("[{\"a\": {\"b\": null}}," + "{\"a\": {\"b\": null}}]")
110133
.build());
111134
}
112135

@@ -388,14 +411,31 @@ public void requestWithArrayContainingFieldThatIsSometimesNull() throws IOExcept
388411
.withContents(tableWithHeader("Path", "Type", "Description")
389412
.row("`assets[].name`", "`String`", "one"));
390413
new RequestFieldsSnippet(Arrays.asList(fieldWithPath("assets[].name")
391-
.description("one").type(JsonFieldType.STRING)))
414+
.description("one").type(JsonFieldType.STRING).optional()))
392415
.document(this.operationBuilder.request("http://localhost")
393416
.content("{\"assets\": [" + "{\"name\": \"sample1\"}, "
394417
+ "{\"name\": null}, "
395418
+ "{\"name\": \"sample2\"}]}")
396419
.build());
397420
}
398421

422+
@Test
423+
public void optionalFieldBeneathArrayThatIsSometimesAbsent() throws IOException {
424+
this.snippets.expectRequestFields()
425+
.withContents(tableWithHeader("Path", "Type", "Description")
426+
.row("`a[].b`", "`Number`", "one")
427+
.row("`a[].c`", "`Number`", "two"));
428+
new RequestFieldsSnippet(Arrays.asList(
429+
fieldWithPath("a[].b").description("one").type(JsonFieldType.NUMBER)
430+
.optional(),
431+
fieldWithPath("a[].c").description("two").type(JsonFieldType.NUMBER)))
432+
.document(
433+
this.operationBuilder.request("http://localhost")
434+
.content("{\"a\":[{\"b\": 1,\"c\": 2}, "
435+
+ "{\"c\": 2}, {\"b\": 1,\"c\": 2}]}")
436+
.build());
437+
}
438+
399439
private String escapeIfNecessary(String input) {
400440
if (this.templateFormat.equals(TemplateFormats.markdown())) {
401441
return input;

0 commit comments

Comments
 (0)