Skip to content

Commit 3907b8a

Browse files
committed
change back resolveSubtypes to private, skip composeParentAsOneOf when @Schema(oneOf) is present instead of returning early
1 parent b30c9bd commit 3907b8a

3 files changed

Lines changed: 32 additions & 41 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: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,8 +1103,8 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
11031103
});
11041104

11051105
// Process the explicit @Schema(oneOf = ...) always. When the class also has @JsonSubTypes and
1106-
// jsonSubTypesOneOf is enabled, resolveSubtypes() returns early (explicit oneOf wins),
1107-
// so there is no conflict.
1106+
// jsonSubTypesOneOf is enabled, resolveSubtypes() skips composing the parent as oneOf
1107+
// (explicit oneOf wins), so there is no conflict.
11081108
List<Class<?>> oneOfFiltered = Stream.of(oneOf)
11091109
.distinct()
11101110
.filter(c -> !this.shouldIgnoreClass(c))
@@ -2032,24 +2032,12 @@ protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotat
20322032
return modified;
20332033
}
20342034

2035-
protected boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConverterContext context, JsonView jsonViewAnnotation) {
2035+
private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConverterContext context, JsonView jsonViewAnnotation) {
20362036
final List<NamedType> types = _intr().findSubtypes(_mapper.serializationConfig(), bean.getClassInfo());
20372037
if (types == null || types.isEmpty()) {
20382038
return false;
20392039
}
20402040

2041-
// When opted in, an explicit @Schema(oneOf = ...) fully defines the polymorphism and must
2042-
// win over the @JsonSubTypes-derived composition. Skip the automatic allOf/oneOf
2043-
// composition to avoid the recursive allOf<->oneOf structure that is undesirable for
2044-
// many tools (see review on swagger-api/swagger-core#5320).
2045-
if (jsonSubTypesOneOf) {
2046-
io.swagger.v3.oas.annotations.media.Schema declaredSchema =
2047-
AnnotationsUtils.getSchemaDeclaredAnnotation(bean.getClassInfo().getAnnotated());
2048-
if (declaredSchema != null && declaredSchema.oneOf().length > 0) {
2049-
return false;
2050-
}
2051-
}
2052-
20532041
/**
20542042
* Remove the current class from the child classes. This happens if @JsonSubTypes references
20552043
* the annotated class as a subtype.
@@ -2120,7 +2108,13 @@ protected boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConve
21202108
}
21212109
}
21222110

2123-
if (jsonSubTypesOneOf && !subtypeNames.isEmpty()) {
2111+
// An explicit @Schema(oneOf = ...) defines the parent's composition and wins over the
2112+
// @JsonSubTypes-derived one, which avoids a recursive allOf<->oneOf structure. Subtype
2113+
// handling is unaffected.
2114+
final io.swagger.v3.oas.annotations.media.Schema declaredSchema =
2115+
AnnotationsUtils.getSchemaDeclaredAnnotation(beanClass);
2116+
final boolean explicitOneOf = declaredSchema != null && declaredSchema.oneOf().length > 0;
2117+
if (jsonSubTypesOneOf && !explicitOneOf && !subtypeNames.isEmpty()) {
21242118
composeParentAsOneOf(model, beanClass, subtypeNames.values(), context);
21252119
}
21262120
return count != 0;

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

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22

33
import com.fasterxml.jackson.annotation.JsonSubTypes;
44
import com.fasterxml.jackson.annotation.JsonTypeInfo;
5-
import com.fasterxml.jackson.annotation.JsonView;
6-
import tools.jackson.databind.BeanDescription;
75
import tools.jackson.databind.ObjectMapper;
86
import io.swagger.v3.core.converter.AnnotatedType;
9-
import io.swagger.v3.core.converter.ModelConverterContext;
107
import io.swagger.v3.core.converter.ModelConverterContextImpl;
118
import io.swagger.v3.core.jackson.ModelResolver;
129
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
@@ -225,28 +222,6 @@ public void jsonSubTypesOnlyParentIsComposedIntoOneOfWhenOptedIn() {
225222
assertEquals(model.getOneOf().get(1).get$ref(), "#/components/schemas/PolymorphicSubtype2");
226223
}
227224

228-
@Test
229-
public void extensionPointAllowsSubclassToRestorePreviousBehavior() {
230-
ModelResolver.jsonSubTypesOneOf = true;
231-
final ModelConverterContextImpl subclassContext =
232-
new ModelConverterContextImpl(new RestorePreviousBehaviorModelResolver(new ObjectMapper()));
233-
final Schema<?> restoredModel = subclassContext.resolve(new AnnotatedType(PolymorphicInterface.class));
234-
assertFalse(restoredModel instanceof ComposedSchema && ((ComposedSchema) restoredModel).getOneOf() != null,
235-
"the resolved parent must not be auto-converted to a oneOf ComposedSchema when resolveSubtypes() is overridden");
236-
}
237-
238-
static class RestorePreviousBehaviorModelResolver extends ModelResolver {
239-
RestorePreviousBehaviorModelResolver(ObjectMapper mapper) {
240-
super(mapper);
241-
}
242-
243-
@Override
244-
protected boolean resolveSubtypes(Schema model, BeanDescription bean,
245-
ModelConverterContext context, JsonView jsonViewAnnotation) {
246-
return false;
247-
}
248-
}
249-
250225
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true)
251226
@JsonSubTypes({
252227
@JsonSubTypes.Type(value = PolymorphicSubtype1.class, name = "subtype1"),
@@ -322,6 +297,16 @@ public void optInExplicitSchemaOneOfWinsOverJsonSubTypes() {
322297
assertEquals(model.getOneOf().size(), 2);
323298
assertEquals(model.getOneOf().get(0).get$ref(), "#/components/schemas/SubBean1");
324299
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+
}
325310
}
326311

327312
@Test

0 commit comments

Comments
 (0)