From 940a0f49ca8719b2dfaf28f19f32809f3ba5a5d6 Mon Sep 17 00:00:00 2001 From: Daniel Munzinger Date: Tue, 14 Jul 2026 09:49:52 +0200 Subject: [PATCH] fix(model-resolver): retain identical recursive properties Swagger's ModelConverterContextImpl uses AnnotatedType to track types currently being processed to prevent infinite loops. However, AnnotatedType equality previously ignored the parent schema. This caused properties with identical signatures (same type, name, annotations) but belonging to different parent classes to be incorrectly treated as a recursion cycle, returning a null schema and dropping the property entirely. This commit updates AnnotatedType's equals() and hashCode() to include the parent schema's name when checking equality for schema properties. This guarantees identical properties in different classes are tracked independently, while still properly catching true self-referential cycles. --- .../v3/core/converter/AnnotatedType.java | 7 ++- .../RecursivePropertyMissingTest.java | 43 +++++++++++++++++++ .../ArrayOfSubclassTest_expected30.json | 9 ++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/RecursivePropertyMissingTest.java diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/AnnotatedType.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/AnnotatedType.java index 537a9d5dde..16000fcfff 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/AnnotatedType.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/AnnotatedType.java @@ -277,9 +277,13 @@ public boolean equals(Object o) { AnnotatedType that = (AnnotatedType) o; List thisAnnotations = getProcessedAnnotations(this.ctxAnnotations); List thatAnnotations = getProcessedAnnotations(that.ctxAnnotations); + String thisParentName = this.parent != null ? this.parent.getName() : null; + String thatParentName = that.parent != null ? that.parent.getName() : null; + return includePropertiesWithoutJSONView == that.includePropertiesWithoutJSONView && schemaProperty == that.schemaProperty && isSubtype == that.isSubtype && + (!schemaProperty || Objects.equals(thisParentName, thatParentName)) && Objects.equals(type, that.type) && Objects.equals(thisAnnotations, thatAnnotations) && Objects.equals(jsonViewAnnotation, that.jsonViewAnnotation) && @@ -289,7 +293,8 @@ public boolean equals(Object o) { @Override public int hashCode() { List processedAnnotations = getProcessedAnnotations(this.ctxAnnotations); - return Objects.hash(type, jsonViewAnnotation, includePropertiesWithoutJSONView, processedAnnotations, schemaProperty, isSubtype, schemaProperty ? propertyName : null); + String parentName = (schemaProperty && this.parent != null) ? this.parent.getName() : null; + return Objects.hash(type, jsonViewAnnotation, includePropertiesWithoutJSONView, processedAnnotations, schemaProperty, isSubtype, schemaProperty ? propertyName : null, parentName); } private boolean processableAnnotationPackage(Package pkg) { diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/RecursivePropertyMissingTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/RecursivePropertyMissingTest.java new file mode 100644 index 0000000000..60fa5b80e8 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/RecursivePropertyMissingTest.java @@ -0,0 +1,43 @@ +package io.swagger.v3.core.resolving; + +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.media.Schema; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertEquals; + +public class RecursivePropertyMissingTest { + + static class WrapperDTO { + @JsonProperty("nodes") + @JsonPropertyDescription("Child nodes") + public List nodes; + } + + static class TestNodeDTO { + @JsonProperty("name") + public String name; + + @JsonProperty("nodes") + @JsonPropertyDescription("Child nodes") + public List nodes; + } + + @Test + public void testRecursivePropertyNotMissing() { + Map schemas = ModelConverters.getInstance().readAll(WrapperDTO.class); + Schema testNodeDTOSchema = schemas.get("TestNodeDTO"); + assertNotNull(testNodeDTOSchema); + + Map properties = testNodeDTOSchema.getProperties(); + assertNotNull(properties, "Properties should not be null"); + assertNotNull(properties.get("nodes"), "The 'nodes' property is missing from TestNodeDTO schema"); + } +} diff --git a/modules/swagger-core/src/test/resources/converting/ArrayOfSubclassTest_expected30.json b/modules/swagger-core/src/test/resources/converting/ArrayOfSubclassTest_expected30.json index 7cac7074ed..3d4cef01f3 100644 --- a/modules/swagger-core/src/test/resources/converting/ArrayOfSubclassTest_expected30.json +++ b/modules/swagger-core/src/test/resources/converting/ArrayOfSubclassTest_expected30.json @@ -79,6 +79,15 @@ }, "friend" : { "type" : "string" + }, + "baseArray" : { + "minItems" : 0, + "uniqueItems" : true, + "type" : "array", + "description" : "Thingy", + "items" : { + "$ref" : "#/components/schemas/Base" + } } }, "description" : "The SubB class"