Consider optionality when finding uncommon fields in subsection extraction

Fixes gh-573
This commit is contained in:
Andy Wilkinson
2019-09-18 14:26:40 +01:00
parent 3f66b066f7
commit 93a43dfd0a
6 changed files with 104 additions and 18 deletions

View File

@@ -151,7 +151,8 @@ public abstract class AbstractFieldsSnippet extends TemplatedSnippet {
}
MediaType contentType = getContentType(operation);
if (this.subsectionExtractor != null) {
content = verifyContent(this.subsectionExtractor.extractSubsection(content, contentType));
content = verifyContent(
this.subsectionExtractor.extractSubsection(content, contentType, this.fieldDescriptors));
}
ContentHandler contentHandler = ContentHandler.forContentWithDescriptors(content, contentType,
this.fieldDescriptors);

View File

@@ -17,9 +17,11 @@
package org.springframework.restdocs.payload;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -71,6 +73,11 @@ public class FieldPathPayloadSubsectionExtractor
@Override
public byte[] extractSubsection(byte[] payload, MediaType contentType) {
return extractSubsection(payload, contentType, Collections.emptyList());
}
@Override
public byte[] extractSubsection(byte[] payload, MediaType contentType, List<FieldDescriptor> descriptors) {
try {
ExtractedField extractedField = new JsonFieldProcessor().extract(this.fieldPath,
objectMapper.readValue(payload, Object.class));
@@ -78,21 +85,27 @@ public class FieldPathPayloadSubsectionExtractor
if (value == ExtractedField.ABSENT) {
throw new PayloadHandlingException(this.fieldPath + " does not identify a section of the payload");
}
Map<JsonFieldPath, FieldDescriptor> descriptorsByPath = descriptors.stream()
.collect(Collectors.toMap(
(descriptor) -> JsonFieldPath.compile(this.fieldPath + "." + descriptor.getPath()),
this::prependFieldPath));
if (value instanceof List) {
List<?> extractedList = (List<?>) value;
Set<String> uncommonPaths = JsonFieldPaths.from(extractedList).getUncommon();
JsonContentHandler contentHandler = new JsonContentHandler(payload, descriptorsByPath.values());
Set<JsonFieldPath> uncommonPaths = JsonFieldPaths.from(extractedList).getUncommon().stream()
.map((path) -> JsonFieldPath.compile(this.fieldPath + "." + path)).filter((path) -> {
FieldDescriptor descriptorForPath = descriptorsByPath.getOrDefault(path,
new FieldDescriptor(path.toString()));
return contentHandler.isMissing(descriptorForPath);
}).collect(Collectors.toSet());
if (uncommonPaths.isEmpty()) {
value = extractedList.get(0);
}
else {
String message = this.fieldPath + " identifies multiple sections of "
+ "the payload and they do not have a common structure. The "
+ "following uncommon paths were found: ";
List<String> prefixedPaths = new ArrayList<>();
for (String uncommonPath : uncommonPaths) {
prefixedPaths.add(this.fieldPath + "." + uncommonPath);
}
message += prefixedPaths;
+ "following non-optional uncommon paths were found: ";
message += uncommonPaths;
throw new PayloadHandlingException(message);
}
}
@@ -103,6 +116,14 @@ public class FieldPathPayloadSubsectionExtractor
}
}
private FieldDescriptor prependFieldPath(FieldDescriptor original) {
FieldDescriptor prefixed = new FieldDescriptor(this.fieldPath + "." + original.getPath());
if (original.isOptional()) {
prefixed.optional();
}
return prefixed;
}
@Override
public String getSubsectionId() {
return this.subsectionId;

View File

@@ -44,9 +44,9 @@ class JsonContentHandler implements ContentHandler {
private final byte[] rawContent;
private final List<FieldDescriptor> fieldDescriptors;
private final Collection<FieldDescriptor> fieldDescriptors;
JsonContentHandler(byte[] content, List<FieldDescriptor> fieldDescriptors) {
JsonContentHandler(byte[] content, Collection<FieldDescriptor> fieldDescriptors) {
this.rawContent = content;
this.fieldDescriptors = fieldDescriptors;
readContent();
@@ -55,10 +55,8 @@ class JsonContentHandler implements ContentHandler {
@Override
public List<FieldDescriptor> findMissingFields() {
List<FieldDescriptor> missingFields = new ArrayList<>();
Object payload = readContent();
for (FieldDescriptor fieldDescriptor : this.fieldDescriptors) {
if (!fieldDescriptor.isOptional() && !this.fieldProcessor.hasField(fieldDescriptor.getPath(), payload)
&& !isNestedBeneathMissingOptionalField(fieldDescriptor, payload)) {
if (isMissing(fieldDescriptor)) {
missingFields.add(fieldDescriptor);
}
}
@@ -66,11 +64,17 @@ class JsonContentHandler implements ContentHandler {
return missingFields;
}
private boolean isNestedBeneathMissingOptionalField(FieldDescriptor missing, Object payload) {
boolean isMissing(FieldDescriptor descriptor) {
Object payload = readContent();
return !descriptor.isOptional() && !this.fieldProcessor.hasField(descriptor.getPath(), payload)
&& !isNestedBeneathMissingOptionalField(descriptor, payload);
}
private boolean isNestedBeneathMissingOptionalField(FieldDescriptor descriptor, Object payload) {
List<FieldDescriptor> candidates = new ArrayList<>(this.fieldDescriptors);
candidates.remove(missing);
candidates.remove(descriptor);
for (FieldDescriptor candidate : candidates) {
if (candidate.isOptional() && missing.getPath().startsWith(candidate.getPath())
if (candidate.isOptional() && descriptor.getPath().startsWith(candidate.getPath())
&& isMissing(candidate, payload)) {
return true;
}

View File

@@ -55,6 +55,26 @@ final class JsonFieldPath {
return this.segments;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
JsonFieldPath other = (JsonFieldPath) obj;
return this.segments.equals(other.segments);
}
@Override
public int hashCode() {
return this.segments.hashCode();
}
@Override
public String toString() {
return this.rawPath;

View File

@@ -16,6 +16,8 @@
package org.springframework.restdocs.payload;
import java.util.List;
import org.springframework.http.MediaType;
/**
@@ -36,6 +38,19 @@ public interface PayloadSubsectionExtractor<T extends PayloadSubsectionExtractor
*/
byte[] extractSubsection(byte[] payload, MediaType contentType);
/**
* Extracts a subsection of the given {@code payload} that has the given
* {@code contentType} and that is described by the given {@code descriptors}.
* @param payload the payload
* @param contentType the content type of the payload
* @param descriptors descriptors that describe the payload
* @return the subsection of the payload
* @since 2.0.4
*/
default byte[] extractSubsection(byte[] payload, MediaType contentType, List<FieldDescriptor> descriptors) {
return extractSubsection(payload, contentType);
}
/**
* Returns an identifier for the subsection that this extractor will extract.
* @return the identifier

View File

@@ -17,6 +17,7 @@
package org.springframework.restdocs.payload;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
import com.fasterxml.jackson.core.JsonParseException;
@@ -101,11 +102,35 @@ public class FieldPathPayloadSubsectionExtractorTests {
public void extractMapSubsectionWithVaryingStructureFromMultiElementArrayInAJsonMap()
throws JsonParseException, JsonMappingException, IOException {
this.thrown.expect(PayloadHandlingException.class);
this.thrown.expectMessage("The following uncommon paths were found: [a.[].b.d]");
this.thrown.expectMessage("The following non-optional uncommon paths were found: [a.[].b.d]");
new FieldPathPayloadSubsectionExtractor("a.[].b").extractSubsection(
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6, \"d\": 7}}]}".getBytes(), MediaType.APPLICATION_JSON);
}
@Test
@SuppressWarnings("unchecked")
public void extractMapSubsectionWithVaryingStructureDueToOptionalFieldsFromMultiElementArrayInAJsonMap()
throws JsonParseException, JsonMappingException, IOException {
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a.[].b").extractSubsection(
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6, \"d\": 7}}]}".getBytes(), MediaType.APPLICATION_JSON,
Arrays.asList(new FieldDescriptor("d").optional()));
Map<String, Object> extracted = new ObjectMapper().readValue(extractedPayload, Map.class);
assertThat(extracted.size()).isEqualTo(1);
assertThat(extracted).containsOnlyKeys("c");
}
@Test
@SuppressWarnings("unchecked")
public void extractMapSubsectionWithVaryingStructureDueToOptionalParentFieldsFromMultiElementArrayInAJsonMap()
throws JsonParseException, JsonMappingException, IOException {
byte[] extractedPayload = new FieldPathPayloadSubsectionExtractor("a.[].b").extractSubsection(
"{\"a\":[{\"b\":{\"c\":5}},{\"b\":{\"c\":6, \"d\": { \"e\": 7}}}]}".getBytes(),
MediaType.APPLICATION_JSON, Arrays.asList(new FieldDescriptor("d").optional()));
Map<String, Object> extracted = new ObjectMapper().readValue(extractedPayload, Map.class);
assertThat(extracted.size()).isEqualTo(1);
assertThat(extracted).containsOnlyKeys("c");
}
@Test
public void extractedSubsectionIsPrettyPrintedWhenInputIsPrettyPrinted()
throws JsonParseException, JsonMappingException, JsonProcessingException, IOException {