Skip to content

Commit 0a5556a

Browse files
authored
#4852 fix: allow recursive models to process without StackOverflow (#5004)
This PR adds a cycle-detection safeguard to ModelResolver to prevent infinite recursion when resolving array schemas that reference subclasses or self-referential types. It introduces a dedicated helper method resolveArraySchemaWithCycleGuard(...), which tracks types currently being resolved in a typesBeingResolved set and ensures they are safely added and removed using a try/finally block. This change fixes StackOverflowError occurrences (issue #4852) when generating schemas for models containing recursive array structures, and adds regression tests with JSON fixtures for both OpenAPI 3.0 and 3.1 to verify correct schema output.
1 parent 8260e8f commit 0a5556a

File tree

5 files changed

+318
-1
lines changed

5 files changed

+318
-1
lines changed

modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver
141141

142142
protected ValidatorProcessor validatorProcessor;
143143

144+
protected Set<AnnotatedType> typesBeingResolved = new HashSet<>();
145+
144146
public ModelResolver(ObjectMapper mapper) {
145147
super(mapper);
146148
}
@@ -829,7 +831,8 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
829831
if (reResolvedProperty.isPresent()) {
830832
property = reResolvedProperty.get();
831833
}
832-
reResolvedProperty = AnnotationsUtils.getArraySchema(ctxArraySchema, annotatedType.getComponents(), null, openapi31, property, true);
834+
835+
reResolvedProperty = resolveArraySchemaWithCycleGuard(ctxArraySchema, annotatedType, openapi31, property);
833836
if (reResolvedProperty.isPresent()) {
834837
property = reResolvedProperty.get();
835838
}
@@ -3608,4 +3611,24 @@ protected boolean applySchemaResolution() {
36083611
(Boolean.parseBoolean(System.getProperty(Schema.APPLY_SCHEMA_RESOLUTION_PROPERTY, "false")) ||
36093612
Boolean.parseBoolean(System.getenv(Schema.APPLY_SCHEMA_RESOLUTION_PROPERTY)));
36103613
}
3614+
3615+
private Optional<Schema> resolveArraySchemaWithCycleGuard(
3616+
io.swagger.v3.oas.annotations.media.ArraySchema ctxArraySchema,
3617+
AnnotatedType annotatedType,
3618+
boolean openapi31,
3619+
Schema<?> property) {
3620+
boolean processSchemaImplementation = !typesBeingResolved.contains(annotatedType);
3621+
Optional<Schema> reResolvedProperty;
3622+
if (processSchemaImplementation) {
3623+
typesBeingResolved.add(annotatedType);
3624+
} try {
3625+
reResolvedProperty = AnnotationsUtils.getArraySchema(ctxArraySchema, annotatedType.getComponents(), null,
3626+
openapi31, property, processSchemaImplementation );
3627+
} finally {
3628+
if (processSchemaImplementation) {
3629+
typesBeingResolved.remove(annotatedType);
3630+
}
3631+
}
3632+
return reResolvedProperty;
3633+
}
36113634
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.swagger.v3.core.converting;
2+
3+
import io.swagger.v3.core.converter.ModelConverters;
4+
import io.swagger.v3.core.converter.ResolvedSchema;
5+
import io.swagger.v3.core.oas.models.ModelWithArrayOfSubclasses;
6+
import io.swagger.v3.core.util.Json31;
7+
8+
import static org.testng.Assert.assertEquals;
9+
import static org.testng.Assert.assertNotNull;
10+
import org.testng.annotations.Test;
11+
12+
import java.nio.file.Files;
13+
import java.nio.file.Paths;
14+
import com.fasterxml.jackson.databind.ObjectMapper;
15+
import com.fasterxml.jackson.databind.JsonNode;
16+
17+
18+
public class ArrayOfSubclassTest {
19+
20+
@Test
21+
public void extractSubclassArray_oas31() throws Exception {
22+
ResolvedSchema schema = ModelConverters.getInstance(true).readAllAsResolvedSchema(ModelWithArrayOfSubclasses.Holder.class);
23+
assertNotNull(schema);
24+
String expectedJson = new String(Files.readAllBytes(Paths.get("src/test/java/io/swagger/v3/core/converting/ArrayOfSubclassTest_expected31.json")));
25+
String actualJson = Json31.pretty(schema);
26+
ObjectMapper mapper = new ObjectMapper();
27+
JsonNode expectedNode = mapper.readTree(expectedJson);
28+
JsonNode actualNode = mapper.readTree(actualJson);
29+
assertEquals(actualNode, expectedNode);
30+
}
31+
32+
@Test
33+
public void extractSubclassArray_oas30() throws Exception {
34+
ResolvedSchema schema = ModelConverters.getInstance(false).readAllAsResolvedSchema(ModelWithArrayOfSubclasses.Holder.class);
35+
assertNotNull(schema);
36+
String expectedJson = new String(Files.readAllBytes(Paths.get("src/test/java/io/swagger/v3/core/converting/ArrayOfSubclassTest_expected30.json")));
37+
String actualJson = Json31.pretty(schema);
38+
ObjectMapper mapper = new ObjectMapper();
39+
JsonNode expectedNode = mapper.readTree(expectedJson);
40+
JsonNode actualNode = mapper.readTree(actualJson);
41+
assertEquals(actualNode, expectedNode);
42+
}
43+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"schema" : {
3+
"description" : "The holder",
4+
"properties" : {
5+
"name" : {
6+
"type" : "string"
7+
},
8+
"friend" : {
9+
"type" : "string"
10+
},
11+
"baseArray" : {
12+
"type" : "array",
13+
"description" : "Thingy",
14+
"items" : {
15+
"$ref" : "#/components/schemas/Base"
16+
},
17+
"minItems" : 0,
18+
"uniqueItems" : true
19+
}
20+
}
21+
},
22+
"referencedSchemas" : {
23+
"Base" : {
24+
"description" : "Stuff",
25+
"discriminator" : {
26+
"propertyName" : "name",
27+
"mapping" : {
28+
"a" : "#/components/schemas/SubA",
29+
"b" : "#/components/schemas/SubB"
30+
}
31+
},
32+
"properties" : {
33+
"name" : {
34+
"type" : "string"
35+
}
36+
}
37+
},
38+
"Holder" : {
39+
"description" : "The holder",
40+
"properties" : {
41+
"name" : {
42+
"type" : "string"
43+
},
44+
"friend" : {
45+
"type" : "string"
46+
},
47+
"baseArray" : {
48+
"type" : "array",
49+
"description" : "Thingy",
50+
"items" : {
51+
"$ref" : "#/components/schemas/Base"
52+
},
53+
"minItems" : 0,
54+
"uniqueItems" : true
55+
}
56+
}
57+
},
58+
"SubA" : {
59+
"description" : "The SubA class",
60+
"properties" : {
61+
"name" : {
62+
"type" : "string"
63+
},
64+
"count" : {
65+
"type" : "integer",
66+
"format" : "int64"
67+
}
68+
}
69+
},
70+
"SubB" : {
71+
"description" : "The SubB class",
72+
"properties" : {
73+
"name" : {
74+
"type" : "string"
75+
},
76+
"friend" : {
77+
"type" : "string"
78+
}
79+
}
80+
}
81+
}
82+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"schema" : {
3+
"type" : "object",
4+
"description" : "The holder",
5+
"properties" : {
6+
"name" : {
7+
"type" : "string"
8+
},
9+
"friend" : {
10+
"type" : "string"
11+
},
12+
"baseArray" : {
13+
"type" : "array",
14+
"description" : "Thingy",
15+
"items" : {
16+
"$ref" : "#/components/schemas/Base",
17+
"description" : "Thingy",
18+
"minItems" : 0,
19+
"uniqueItems" : true
20+
},
21+
"minItems" : 0,
22+
"uniqueItems" : true
23+
}
24+
}
25+
},
26+
"referencedSchemas" : {
27+
"Base" : {
28+
"type" : "object",
29+
"description" : "Stuff",
30+
"discriminator" : {
31+
"propertyName" : "name",
32+
"mapping" : {
33+
"a" : "#/components/schemas/SubA",
34+
"b" : "#/components/schemas/SubB"
35+
}
36+
},
37+
"properties" : {
38+
"name" : {
39+
"type" : "string"
40+
}
41+
}
42+
},
43+
"Holder" : {
44+
"type" : "object",
45+
"description" : "The holder",
46+
"properties" : {
47+
"name" : {
48+
"type" : "string"
49+
},
50+
"friend" : {
51+
"type" : "string"
52+
},
53+
"baseArray" : {
54+
"type" : "array",
55+
"description" : "Thingy",
56+
"items" : {
57+
"$ref" : "#/components/schemas/Base",
58+
"description" : "Thingy",
59+
"minItems" : 0,
60+
"uniqueItems" : true
61+
},
62+
"minItems" : 0,
63+
"uniqueItems" : true
64+
}
65+
}
66+
},
67+
"SubA" : {
68+
"type" : "object",
69+
"description" : "The SubA class",
70+
"properties" : {
71+
"name" : {
72+
"type" : "string"
73+
},
74+
"count" : {
75+
"type" : "integer",
76+
"format" : "int64"
77+
}
78+
}
79+
},
80+
"SubB" : {
81+
"type" : "object",
82+
"description" : "The SubB class",
83+
"properties" : {
84+
"name" : {
85+
"type" : "string"
86+
},
87+
"friend" : {
88+
"type" : "string"
89+
},
90+
"baseArray" : {
91+
"type" : "array",
92+
"description" : "Thingy",
93+
"items" : {
94+
"$ref" : "#/components/schemas/Base",
95+
"description" : "Thingy",
96+
"minItems" : 0,
97+
"uniqueItems" : true
98+
},
99+
"minItems" : 0,
100+
"uniqueItems" : true
101+
}
102+
}
103+
}
104+
}
105+
}
106+
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package io.swagger.v3.core.oas.models;
2+
3+
import io.swagger.v3.oas.annotations.media.ArraySchema;
4+
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
public class ModelWithArrayOfSubclasses {
8+
9+
@Schema(description = "The holder")
10+
public class Holder extends SubB {
11+
}
12+
13+
@Schema(
14+
discriminatorProperty = "name"
15+
, discriminatorMapping = {
16+
@DiscriminatorMapping(schema = SubA.class, value = "a")
17+
, @DiscriminatorMapping(schema = SubB.class, value = "b")
18+
}
19+
, description = "Stuff"
20+
)
21+
public class Base {
22+
23+
private String name;
24+
25+
public String getName() {
26+
return name;
27+
}
28+
}
29+
30+
@Schema(description = "The SubA class")
31+
public class SubA extends Base {
32+
33+
private Long count;
34+
35+
public Long getCount() {
36+
return count;
37+
}
38+
}
39+
40+
@Schema(description = "The SubB class")
41+
public class SubB extends Base {
42+
43+
private String friend;
44+
private Base[] baseArray;
45+
46+
public String getFriend() {
47+
return friend;
48+
}
49+
50+
@ArraySchema(
51+
schema = @Schema(implementation = Base.class)
52+
, arraySchema = @Schema(
53+
type = "array"
54+
, description = "Thingy"
55+
)
56+
, minItems = 0
57+
, uniqueItems = true
58+
)
59+
public Base[] getBaseArray() {
60+
return baseArray;
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)