Skip to content

Commit 6e29b02

Browse files
feat: optionally compose @JsonSubTypes parents into oneOf (#5320)
* feat: optionally compose @JsonSubTypes parents into oneOf Adds an opt-in (json-subtypes-oneof system property / ModelResolver.jsonSubTypesOneOf) that composes a @JsonSubTypes or @Schema(subTypes) parent into a self-referencing oneOf: the parent gets a oneOf to its subtypes while the subtypes keep allOf to the parent. The cyclic allOf <-> oneOf shape is off by default because swagger-ui, openapi-generator and others dislike it, so the default output is unchanged from 3.0.0. When enabled, resolveSubtypes() replaces the parent model with a ComposedSchema whose oneOf references the resolved subtypes (using the resolved @JsonView-aware names) and re-reads the parent from the context; an explicit @Schema(oneOf) wins over the automatic composition to avoid a recursive allOf <-> oneOf. resolveSubtypes() is now protected instead of private, providing an extension point for downstream ModelResolver subclasses that need to customize or disable subtype resolution (see #5030). * make oneOf use only resolved subtypes, clean up and additional tests * change back resolveSubtypes to private, skip `composeParentAsOneOf` when `@Schema(oneOf)` is present instead of returning early --------- Co-authored-by: Ewa Ostrowska <ewa.ostrowska@smartbear.com>
1 parent 9d83dc2 commit 6e29b02

3 files changed

Lines changed: 245 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Added
10+
- Opt-in `oneOf` composition for `@JsonSubTypes` parents. Enable it with
11+
`-Djson-subtypes-oneof` or `ModelResolver.jsonSubTypesOneOf = true`. When enabled,
12+
a parent class with `@JsonSubTypes` gets a `oneOf` that lists its resolved subtypes,
13+
and the subtypes keep their `allOf` reference to the parent. An explicit
14+
`@Schema(oneOf = ...)` on the parent takes priority. The default is off, so the
15+
generated schemas do not change unless you enable it. Use it for abstract classes and
16+
interfaces. A concrete parent class is not listed in its own `oneOf`, so an instance of
17+
the parent itself does not match the schema. Note: the result has a cycle
18+
(`allOf` from subtype to parent, `oneOf` from parent to subtype). Some tools, such as
19+
swagger-ui and openapi-generator, may not handle this shape well.
20+
921
## [2.2.54] - 2026-08-18
1022

1123
### Fixed

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
import java.lang.reflect.Type;
9595
import java.math.BigDecimal;
9696
import java.util.ArrayList;
97+
import java.util.Collection;
9798
import java.util.Arrays;
9899
import java.util.Collections;
99100
import java.util.HashMap;
@@ -122,6 +123,7 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver
122123

123124
public static final String SET_PROPERTY_OF_COMPOSED_MODEL_AS_SIBLING = "composed-model-properties-as-sibiling";
124125
public static final String SET_PROPERTY_OF_ENUMS_AS_REF = "enums-as-ref";
126+
public static final String SET_PROPERTY_OF_JSON_SUBTYPES_ONE_OF = "json-subtypes-oneof";
125127

126128
public static boolean composedModelPropertiesAsSibling = System.getProperty(SET_PROPERTY_OF_COMPOSED_MODEL_AS_SIBLING) != null;
127129

@@ -138,6 +140,21 @@ public class ModelResolver extends AbstractModelConverter implements ModelConver
138140
*/
139141
public static boolean enumsAsRef = System.getProperty(SET_PROPERTY_OF_ENUMS_AS_REF) != null;
140142

143+
/**
144+
* Opt-in composition of a {@code @JsonSubTypes} parent into a self-referencing {@code oneOf}
145+
* (the parent gets a {@code oneOf} to its subtypes while the subtypes keep {@code allOf} to the
146+
* parent). This cyclic {@code allOf} &lt;-&gt; {@code oneOf} shape is off by default because several
147+
* tools (swagger-ui, openapi-generator) dislike it; when enabled it can be turned on with
148+
* {@code -Djson-subtypes-oneof} or by setting this field. When enabled, an explicit
149+
* {@code @Schema(oneOf)} on the parent wins over the automatic composition; when disabled,
150+
* the default (3.0.0) behavior applies unchanged.
151+
* <p>
152+
* Note that the resulting shape is also a validation cycle: a subtype must be valid against the
153+
* parent ({@code allOf}) while the parent must be valid against one of its subtypes
154+
* ({@code oneOf}), so strict validators may warn or reject it.
155+
*/
156+
public static boolean jsonSubTypesOneOf = System.getProperty(SET_PROPERTY_OF_JSON_SUBTYPES_ONE_OF) != null;
157+
141158
private boolean openapi31;
142159

143160
private Schema.SchemaResolution schemaResolution = Schema.SchemaResolution.DEFAULT;
@@ -973,6 +990,15 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
973990
model.setDiscriminator(null);
974991
}
975992

993+
// When opted in, resolveSubtypes() may have replaced the parent model in the context with
994+
// a oneOf ComposedSchema; re-read it so subsequent processing sees the composed schema.
995+
if (jsonSubTypesOneOf && !type.isContainerType() && StringUtils.isNotBlank(name)) {
996+
Schema updatedModel = context.getDefinedModels().get(name);
997+
if (updatedModel != null) {
998+
model = updatedModel;
999+
}
1000+
}
1001+
9761002
Discriminator discriminator = resolveDiscriminator(type, context);
9771003
if (discriminator != null) {
9781004
model.setDiscriminator(discriminator);
@@ -1076,6 +1102,9 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
10761102

10771103
});
10781104

1105+
// Process the explicit @Schema(oneOf = ...) always. When the class also has @JsonSubTypes and
1106+
// jsonSubTypesOneOf is enabled, resolveSubtypes() skips composing the parent as oneOf
1107+
// (explicit oneOf wins), so there is no conflict.
10791108
List<Class<?>> oneOfFiltered = Stream.of(oneOf)
10801109
.distinct()
10811110
.filter(c -> !this.shouldIgnoreClass(c))
@@ -2005,7 +2034,7 @@ protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotat
20052034

20062035
private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConverterContext context, JsonView jsonViewAnnotation) {
20072036
final List<NamedType> types = _intr().findSubtypes(_mapper.serializationConfig(), bean.getClassInfo());
2008-
if (types == null) {
2037+
if (types == null || types.isEmpty()) {
20092038
return false;
20102039
}
20112040

@@ -2025,6 +2054,7 @@ private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConvert
20252054

20262055
removeDuplicateSubTypes(types);
20272056

2057+
Map<Class<?>, String> subtypeNames = new LinkedHashMap<>();
20282058
int count = 0;
20292059
final Class<?> beanClass = bean.getClassInfo().getAnnotated();
20302060
for (NamedType subtype : types) {
@@ -2076,10 +2106,39 @@ private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConvert
20762106
Class<?> currentType = subtype.getType();
20772107
if (StringUtils.isNotBlank(composedSchema.getName())) {
20782108
context.defineModel(composedSchema.getName(), composedSchema, new AnnotatedType().type(currentType), null);
2109+
subtypeNames.put(subtypeType, composedSchema.getName());
20792110
}
2111+
}
20802112

2113+
// An explicit @Schema(oneOf = ...) defines the parent's composition and wins over the
2114+
// @JsonSubTypes-derived one, which avoids a recursive allOf<->oneOf structure. Subtype
2115+
// handling is unaffected.
2116+
final io.swagger.v3.oas.annotations.media.Schema declaredSchema =
2117+
AnnotationsUtils.getSchemaDeclaredAnnotation(beanClass);
2118+
final boolean explicitOneOf = declaredSchema != null && declaredSchema.oneOf().length > 0;
2119+
if (jsonSubTypesOneOf && !explicitOneOf && !subtypeNames.isEmpty()) {
2120+
composeParentAsOneOf(model, beanClass, subtypeNames.values(), context);
20812121
}
2082-
return count != 0;
2122+
return count != 0;
2123+
}
2124+
2125+
/**
2126+
* Converts the parent model into a {@code oneOf} of the resolved subtypes and replaces it in the
2127+
* context. This produces the cyclic {@code allOf <-> oneOf} shape and is therefore opt-in
2128+
* (see {@link #jsonSubTypesOneOf}).
2129+
*/
2130+
private void composeParentAsOneOf(Schema model, Class<?> beanClass, Collection<String> subtypeNames, ModelConverterContext context) {
2131+
ComposedSchema oneOfSchema = ComposedSchema.from(model);
2132+
oneOfSchema.setOneOf(new ArrayList<>());
2133+
2134+
for (String subtypeName : subtypeNames) {
2135+
Schema refSchema = openapi31 ? new JsonSchema() : new Schema();
2136+
refSchema.$ref(Components.COMPONENTS_SCHEMAS_REF + subtypeName);
2137+
oneOfSchema.addOneOfItem(refSchema);
2138+
}
2139+
2140+
// Replace parent model in context with oneOf schema
2141+
context.defineModel(model.getName(), oneOfSchema, new AnnotatedType().type(beanClass), null);
20832142
}
20842143

20852144
private void removeSelfFromSubTypes(List<NamedType> types, BeanDescription bean) {

‎modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/JsonSubTypesAndSchemaOneOfTest.java‎

Lines changed: 172 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@
99
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
1010
import io.swagger.v3.oas.models.media.ComposedSchema;
1111
import io.swagger.v3.oas.models.media.Schema;
12+
import org.testng.annotations.AfterMethod;
1213
import org.testng.annotations.AfterTest;
1314
import org.testng.annotations.BeforeMethod;
1415
import org.testng.annotations.Test;
1516

16-
import java.util.HashSet;
17-
import java.util.List;
1817
import java.util.Map;
19-
import java.util.Set;
2018

2119
import static org.testng.Assert.*;
2220

@@ -37,13 +35,20 @@ public class JsonSubTypesAndSchemaOneOfTest extends SwaggerTestBase {
3735
@BeforeMethod
3836
public void setup() {
3937
ModelResolver.composedModelPropertiesAsSibling = false;
38+
ModelResolver.jsonSubTypesOneOf = false;
4039
ModelResolver modelResolver = new ModelResolver(new ObjectMapper());
4140
context = new ModelConverterContextImpl(modelResolver);
4241
}
4342

43+
@AfterMethod
44+
public void resetJsonSubTypesOneOf() {
45+
ModelResolver.jsonSubTypesOneOf = false;
46+
}
47+
4448
@AfterTest
4549
public void afterTest() {
4650
ModelResolver.composedModelPropertiesAsSibling = false;
51+
ModelResolver.jsonSubTypesOneOf = false;
4752
}
4853

4954
@Test
@@ -195,46 +200,180 @@ static class SubBean2InterfaceImplementor extends BaseBeanInterfaceImplementor {
195200
}
196201

197202
@Test
198-
public void duplicateJsonSubTypesEntriesAreDeduplicated() {
199-
// Identical @JsonSubTypes entries (and Jackson's AnnotationIntrospectorPair) can report the
200-
// same subtype more than once. When the parent is composed into a oneOf, each entry would
201-
// otherwise become a repeated $ref.
202-
//
203-
// NOTE: on 3.0.0 alone the parent is not composed into a oneOf, so this test passes
204-
// trivially. It only starts guarding against duplicates once the @JsonSubTypes -> oneOf
205-
// composition lands (swagger-api/swagger-core#5320); with that change and without the
206-
// dedup fix in resolveSubtypes(), the oneOf contains 3 entries instead of 2.
207-
final Schema<?> model = context.resolve(new AnnotatedType(DuplicateSubTypesParent.class));
208-
209-
if (model instanceof ComposedSchema && ((ComposedSchema) model).getOneOf() != null) {
210-
final List<Schema> oneOf = ((ComposedSchema) model).getOneOf();
211-
assertEquals(oneOf.size(), 2, "duplicate @JsonSubTypes entries must be deduplicated");
212-
final Set<String> refs = new HashSet<>();
213-
for (Schema<?> item : oneOf) {
214-
assertTrue(refs.add(item.get$ref()),
215-
"oneOf must not contain a repeated $ref: " + item.get$ref());
216-
}
203+
public void jsonSubTypesOnlyParentHasNoOneOfByDefault() {
204+
// Default behavior (opt-in disabled): a @JsonSubTypes-only type is NOT composed into a oneOf,
205+
// matching the 3.0.0 behavior.
206+
ModelResolver.jsonSubTypesOneOf = false;
207+
final Schema<?> model = context.resolve(new AnnotatedType(PolymorphicInterface.class));
208+
assertNotNull(model);
209+
assertTrue(model.getOneOf() == null || model.getOneOf().isEmpty(),
210+
"with the default (opt-in disabled) the @JsonSubTypes-only parent must not be composed into a oneOf");
211+
}
212+
213+
@Test
214+
public void jsonSubTypesOnlyParentIsComposedIntoOneOfWhenOptedIn() {
215+
ModelResolver.jsonSubTypesOneOf = true;
216+
final Schema<?> model = context.resolve(new AnnotatedType(PolymorphicInterface.class));
217+
assertTrue(model instanceof ComposedSchema,
218+
"when opted in the @JsonSubTypes-only parent should be converted to a oneOf ComposedSchema");
219+
assertNotNull(model.getOneOf());
220+
assertEquals(model.getOneOf().size(), 2);
221+
assertEquals(model.getOneOf().get(0).get$ref(), "#/components/schemas/PolymorphicSubtype1");
222+
assertEquals(model.getOneOf().get(1).get$ref(), "#/components/schemas/PolymorphicSubtype2");
223+
}
224+
225+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true)
226+
@JsonSubTypes({
227+
@JsonSubTypes.Type(value = PolymorphicSubtype1.class, name = "subtype1"),
228+
@JsonSubTypes.Type(value = PolymorphicSubtype2.class, name = "subtype2")
229+
})
230+
interface PolymorphicInterface {
231+
String getType();
232+
String getName();
233+
}
234+
235+
static class PolymorphicSubtype1 implements PolymorphicInterface {
236+
public String type = "subtype1";
237+
public String name = "subtype1";
238+
239+
@Override
240+
public String getType() {
241+
return type;
217242
}
218243

219-
// Regardless of composition, each distinct subtype schema must be defined.
220-
assertTrue(context.getDefinedModels().containsKey("DuplicateSubTypesChild1"));
221-
assertTrue(context.getDefinedModels().containsKey("DuplicateSubTypesChild2"));
244+
@Override
245+
public String getName() {
246+
return name;
247+
}
248+
}
249+
250+
static class PolymorphicSubtype2 implements PolymorphicInterface {
251+
public String type = "subtype2";
252+
public String name = "subtype2";
253+
254+
@Override
255+
public String getType() {
256+
return type;
257+
}
258+
259+
@Override
260+
public String getName() {
261+
return name;
262+
}
263+
}
264+
265+
@Test
266+
public void optInKeepsSubtypesAllOfParentAndParentProperties() {
267+
ModelResolver.jsonSubTypesOneOf = true;
268+
final Schema<?> parent = context.resolve(new AnnotatedType(PolymorphicInterface.class));
269+
assertTrue(parent instanceof ComposedSchema);
270+
assertNotNull(parent.getProperties(), "parent properties must survive the replacement");
271+
assertTrue(parent.getProperties().containsKey("name"));
272+
273+
final Schema<?> sub = context.getDefinedModels().get("PolymorphicSubtype1");
274+
assertTrue(sub instanceof ComposedSchema);
275+
assertEquals(((ComposedSchema) sub).getAllOf().get(0).get$ref(), "#/components/schemas/PolymorphicInterface");
222276
}
223277

224-
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
278+
@Test
279+
public void optInOneOfFollowsDeclarationOrderAndUsesResolvedNames() {
280+
ModelResolver.jsonSubTypesOneOf = true;
281+
final Schema<?> parent = context.resolve(new AnnotatedType(RenamedParent.class));
282+
assertNotNull(parent.getOneOf());
283+
assertEquals(parent.getOneOf().size(), 2);
284+
assertEquals(parent.getOneOf().get(0).get$ref(), "#/components/schemas/RenamedFirst");
285+
assertEquals(parent.getOneOf().get(1).get$ref(), "#/components/schemas/RenamedSecond");
286+
for (Schema<?> ref : parent.getOneOf()) {
287+
final String name = ref.get$ref().substring("#/components/schemas/".length());
288+
assertNotNull(context.getDefinedModels().get(name), "dangling $ref in oneOf: " + name);
289+
}
290+
}
291+
292+
@Test
293+
public void optInExplicitSchemaOneOfWinsOverJsonSubTypes() {
294+
ModelResolver.jsonSubTypesOneOf = true;
295+
final Schema<?> model = context.resolve(new AnnotatedType(BaseBean.class));
296+
assertNotNull(model.getOneOf());
297+
assertEquals(model.getOneOf().size(), 2);
298+
assertEquals(model.getOneOf().get(0).get$ref(), "#/components/schemas/SubBean1");
299+
assertEquals(model.getOneOf().get(1).get$ref(), "#/components/schemas/SubBean2");
300+
301+
// subtype handling and discriminator are identical to the flag-off output
302+
assertEquals(model.getDiscriminator().getPropertyName(), DISCRIMINATOR_PROPERTY_NAME);
303+
assertEquals(model.getDiscriminator().getMapping().get(SUB_BEAN_1_NAME), "#/components/schemas/SubBean1");
304+
assertEquals(model.getDiscriminator().getMapping().get(SUB_BEAN_2_NAME), "#/components/schemas/SubBean2");
305+
for (String subName : new String[]{SUB_BEAN_1_NAME, SUB_BEAN_2_NAME}) {
306+
final Schema<?> sub = context.getDefinedModels().get(subName);
307+
assertTrue(sub instanceof ComposedSchema, subName + " must be composed");
308+
assertEquals(((ComposedSchema) sub).getAllOf().get(0).get$ref(), "#/components/schemas/BaseBean");
309+
}
310+
}
311+
312+
@Test
313+
public void optInProducesOneOfRefsForOpenApi31() {
314+
ModelResolver.jsonSubTypesOneOf = true;
315+
final ModelConverterContextImpl ctx31 =
316+
new ModelConverterContextImpl(new ModelResolver(new ObjectMapper()).openapi31(true));
317+
final Schema<?> model = ctx31.resolve(new AnnotatedType(PolymorphicInterface.class));
318+
assertNotNull(model.getOneOf());
319+
assertEquals(model.getOneOf().size(), 2);
320+
assertEquals(model.getOneOf().get(0).get$ref(), "#/components/schemas/PolymorphicSubtype1");
321+
}
322+
323+
@Test
324+
public void defaultOutputUnchangedForSubtypesParent() {
325+
ModelResolver.jsonSubTypesOneOf = false;
326+
final Schema<?> parent = context.resolve(new AnnotatedType(PolymorphicInterface.class));
327+
assertFalse(parent instanceof ComposedSchema && ((ComposedSchema) parent).getOneOf() != null);
328+
assertTrue(parent.getProperties().containsKey("name"));
329+
final Schema<?> sub = context.getDefinedModels().get("PolymorphicSubtype1");
330+
assertTrue(sub instanceof ComposedSchema);
331+
assertEquals(((ComposedSchema) sub).getAllOf().get(0).get$ref(), "#/components/schemas/PolymorphicInterface");
332+
}
333+
334+
@Test
335+
public void optInOneOfSkipsSubtypesNotAssignableToParent() {
336+
ModelResolver.jsonSubTypesOneOf = true;
337+
final Schema<?> parent = context.resolve(new AnnotatedType(ParentWithUnrelatedSubtype.class));
338+
assertNotNull(parent.getOneOf());
339+
assertEquals(parent.getOneOf().size(), 1);
340+
assertEquals(parent.getOneOf().get(0).get$ref(), "#/components/schemas/RelatedImpl");
341+
}
342+
343+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind")
225344
@JsonSubTypes({
226-
@JsonSubTypes.Type(value = DuplicateSubTypesChild1.class),
227-
@JsonSubTypes.Type(value = DuplicateSubTypesChild1.class),
228-
@JsonSubTypes.Type(value = DuplicateSubTypesChild2.class)
345+
@JsonSubTypes.Type(value = RelatedImpl.class, name = "related"),
346+
@JsonSubTypes.Type(value = UnrelatedType.class, name = "unrelated")
229347
})
230-
static abstract class DuplicateSubTypesParent {
231-
public String type;
348+
static class ParentWithUnrelatedSubtype {
349+
public String id;
350+
}
351+
352+
static class RelatedImpl extends ParentWithUnrelatedSubtype {
353+
public String x;
354+
}
355+
356+
static class UnrelatedType {
357+
public String y;
358+
}
359+
360+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind")
361+
@JsonSubTypes({
362+
@JsonSubTypes.Type(value = RenamedFirstImpl.class, name = "first"),
363+
@JsonSubTypes.Type(value = RenamedSecondImpl.class, name = "second")
364+
})
365+
static class RenamedParent {
366+
public String id;
232367
}
233368

234-
static class DuplicateSubTypesChild1 extends DuplicateSubTypesParent {
369+
@io.swagger.v3.oas.annotations.media.Schema(name = "RenamedFirst")
370+
static class RenamedFirstImpl extends RenamedParent {
371+
public String x;
235372
}
236373

237-
static class DuplicateSubTypesChild2 extends DuplicateSubTypesParent {
374+
@io.swagger.v3.oas.annotations.media.Schema(name = "RenamedSecond")
375+
static class RenamedSecondImpl extends RenamedParent {
376+
public String y;
238377
}
239378

240379
private void assertSubPropertiesValid(Map<String, Schema> subProperties, final String childPropertyName) {

0 commit comments

Comments
 (0)