From aa2bde46fd667af509c12edc6ce31ae54d03b6ad Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Tue, 6 Oct 2026 22:12:01 +0100 Subject: [PATCH] Consider empty array documented when a field beneath it is documented When a field beneath an array is documented, for example items[].k2[].name, removing it from the payload cascades upwards and removes any container that the removal leaves empty. An items[] entry whose k2 array is empty to begin with is never reached by this cascade as it has no name entry to remove. The empty array is left behind and the payload is reported as having undocumented content even though every field in it has been documented. When traversal reaches an empty collection through a non-leaf array segment, it now notifies the callback. The callbacks used for removal remove the empty collection as they would a matched leaf, so that it and any containers it leaves empty are removed. The callbacks used to determine a field's presence and to extract its values ignore the notification so their behavior is unchanged. Fixes gh-626 Signed-off-by: Sharang Gupta --- .../restdocs/payload/JsonFieldProcessor.java | 22 ++++++++++++++++ .../payload/JsonContentHandlerTests.java | 9 +++++++ .../payload/JsonFieldProcessorTests.java | 26 +++++++++++++++++++ .../payload/ResponseFieldsSnippetTests.java | 16 ++++++++++++ 4 files changed, 73 insertions(+) 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 d6048547..82d172a2 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 @@ -31,6 +31,7 @@ * extracted and removed. * * @author Andy Wilkinson + * @author Sharang Gupta * */ final class JsonFieldProcessor { @@ -72,6 +73,11 @@ public void foundMatch(Match match) { match.remove(); } + @Override + public void foundEmptyCollection(Match match) { + match.remove(); + } + }); } @@ -83,6 +89,11 @@ public void foundMatch(Match match) { match.removeSubsection(); } + @Override + public void foundEmptyCollection(Match match) { + match.removeSubsection(); + } + }); } @@ -107,6 +118,9 @@ private void handleCollectionPayload(Collection collection, MatchCallback mat if (context.isLeaf()) { matchCallback.foundMatch(new LeafCollectionMatch(collection, context.getParentMatch())); } + else if (collection.isEmpty()) { + matchCallback.foundEmptyCollection(new LeafCollectionMatch(collection, context.getParentMatch())); + } else { Iterator items = collection.iterator(); while (items.hasNext()) { @@ -369,6 +383,14 @@ private interface MatchCallback { default void absent() { } + /** + * Called when the path descends into a collection that has no entries. The + * collection is covered by the path but contains nothing that can be matched. + * @param match a match for the empty collection + */ + default void foundEmptyCollection(Match match) { + } + } private interface Match { diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonContentHandlerTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonContentHandlerTests.java index e421ab31..c5e5d7af 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonContentHandlerTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/payload/JsonContentHandlerTests.java @@ -225,4 +225,13 @@ void describedMissingFieldThatIsChildOfOptionalObjectThatIsNullIsNotConsideredMi assertThat(missingFields.size()).isEqualTo(0); } + @Test + void describedFieldBeneathArrayThatIsSometimesEmptyDoesNotLeaveUndocumentedContent() { + List descriptors = Arrays.asList(new FieldDescriptor("count"), + new FieldDescriptor("items[].k1"), new FieldDescriptor("items[].k2[].name")); + String content = "{\"count\": 2, \"items\": [{\"k1\": \"v1\", \"k2\": []}, " + + "{\"k1\": \"v2\", \"k2\": [{\"name\": \"joe\"}, {\"name\": \"alice\"}]}]}"; + assertThat(new JsonContentHandler(content.getBytes(), descriptors).getUndocumentedContent()).isNull(); + } + } 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 866663f4..3d61e32f 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 @@ -315,6 +315,32 @@ void removeSubsectionRemovesArrayWithListEntries() { assertThat(payload.size()).isEqualTo(0); } + @SuppressWarnings("unchecked") + @Test + void removeFieldBeneathArrayThatIsSometimesEmpty() { + Map payload = new ObjectMapper() + .readValue("{\"a\": [{\"b\": []}, {\"b\": [{\"c\": \"charlie\"}, {\"c\": \"charlie\"}]}]}", Map.class); + this.fieldProcessor.remove("a[].b[].c", payload); + assertThat(payload.size()).isEqualTo(0); + } + + @SuppressWarnings("unchecked") + @Test + void removeFieldBeneathArrayThatIsAlwaysEmpty() { + Map payload = new ObjectMapper().readValue("{\"a\": [{\"b\": []}]}", Map.class); + this.fieldProcessor.remove("a[].b[].c", payload); + assertThat(payload.size()).isEqualTo(0); + } + + @SuppressWarnings("unchecked") + @Test + void removeSubsectionBeneathArrayThatIsSometimesEmpty() { + Map payload = new ObjectMapper() + .readValue("{\"a\": [{\"b\": []}, {\"b\": [{\"c\": {\"d\": \"delta\"}}]}]}", Map.class); + this.fieldProcessor.removeSubsection("a[].b[].c", payload); + assertThat(payload.size()).isEqualTo(0); + } + @Test void extractNestedEntryWithDotInKeys() { Map payload = new HashMap<>(); 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 2dd06097..4f34b73f 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 @@ -384,6 +384,22 @@ void optionalFieldBeneathArrayThatIsSometimesAbsent(OperationBuilder operationBu .row("`a[].c`", "`Number`", "two")); } + @RenderedSnippetTest + void fieldBeneathArrayThatIsSometimesEmpty(OperationBuilder operationBuilder, AssertableSnippets snippets) + throws IOException { + new ResponseFieldsSnippet( + Arrays.asList(fieldWithPath("count").description("one"), fieldWithPath("items[].k1").description("two"), + fieldWithPath("items[].k2[].name").description("three"))) + .document(operationBuilder.response() + .content("{\"count\": 2, \"items\": [{\"k1\": \"v1\", \"k2\": []}, " + + "{\"k1\": \"v2\", \"k2\": [{\"name\": \"joe\"}, {\"name\": \"alice\"}]}]}") + .build()); + assertThat(snippets.responseFields()).isTable((table) -> table.withHeader("Path", "Type", "Description") + .row("`count`", "`Number`", "one") + .row("`items[].k1`", "`String`", "two") + .row("`items[].k2[].name`", "`String`", "three")); + } + @RenderedSnippetTest void typeDeterminationDoesNotSetTypeOnDescriptor(OperationBuilder operationBuilder, AssertableSnippets snippets) throws IOException {