From e9e41d089330f6654f2b908f0db1c6760b2ad696 Mon Sep 17 00:00:00 2001 From: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:19:00 +0900 Subject: [PATCH] fix: keep polymorphic subtype composition when re-resolved standalone A subtype's allOf reference to its parent is built by the parent's resolveSubtypes and stored via defineModel. When the same subtype is later resolved standalone (e.g. while resolving a discriminatorMapping target, which uses an AnnotatedType without the subtype flag and so misses the resolution cache since #4975/#5005), the plain re-resolution overwrote the stored composed schema, dropping its allOf. Guard model registration so a schema without composition keywords never overwrites an already-registered composed schema of the same name. Fixes #5028 Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> --- .../converter/ModelConverterContextImpl.java | 25 +++++- .../v3/core/resolving/Ticket5028Test.java | 82 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5028Test.java diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/ModelConverterContextImpl.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/ModelConverterContextImpl.java index cd938ac4ac..73820ef992 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/ModelConverterContextImpl.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/converter/ModelConverterContextImpl.java @@ -57,7 +57,7 @@ public void defineModel(String name, Schema model, AnnotatedType type, String pr if (LOGGER.isTraceEnabled()) { LOGGER.trace(String.format("defineModel %s %s", name, model)); } - modelByName.put(name, model); + putModelByName(name, model); if (StringUtils.isNotBlank(prevName) && !prevName.equals(name)) { modelByName.remove(prevName); @@ -68,6 +68,27 @@ public void defineModel(String name, Schema model, AnnotatedType type, String pr } } + /** + * Registers {@code model} under {@code name}, but never lets a schema without composition keywords + * overwrite an already-registered composed schema of the same name. This preserves the {@code allOf} + * reference a polymorphic subtype gains from its parent's {@code resolveSubtypes} when the same subtype + * is later resolved standalone (e.g. while resolving a discriminator mapping), which otherwise clobbers + * it back to a plain object schema. + */ + private void putModelByName(String name, Schema model) { + Schema existing = modelByName.get(name); + if (existing != null && hasComposition(existing) && !hasComposition(model)) { + return; + } + modelByName.put(name, model); + } + + private static boolean hasComposition(Schema schema) { + return (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) + || (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) + || (schema.getOneOf() != null && !schema.getOneOf().isEmpty()); + } + @Override public Map getDefinedModels() { return Collections.unmodifiableMap(modelByName); @@ -101,7 +122,7 @@ public Schema resolve(AnnotatedType type) { Schema resolvedImpl = resolved; if (resolvedImpl.getName() != null) { - modelByName.put(resolvedImpl.getName(), resolved); + putModelByName(resolvedImpl.getName(), resolved); } } else { processedTypes.remove(type); diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5028Test.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5028Test.java new file mode 100644 index 0000000000..1a125ec099 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/Ticket5028Test.java @@ -0,0 +1,82 @@ +package io.swagger.v3.core.resolving; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverterContextImpl; +import io.swagger.v3.core.jackson.ModelResolver; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +import org.testng.annotations.Test; + +import java.util.Map; + +import static io.swagger.v3.core.resolving.SwaggerTestBase.mapper; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +public class Ticket5028Test { + + @Test + public void testSubtypeKeepsCompositionOpenApi30() { + final ModelResolver modelResolver = new ModelResolver(mapper()); + final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver); + + context.resolve(new AnnotatedType(AttributeType.class)); + final Map models = context.getDefinedModels(); + + final io.swagger.v3.oas.models.media.Schema subtype = models.get("DateAttributeTypeImpl"); + assertNotNull(subtype, "subtype schema should be defined"); + assertNotNull(subtype.getAllOf(), + "subtype must keep its allOf reference to the parent, but was: " + subtype); + assertEquals(subtype.getAllOf().size(), 1); + assertEquals(((io.swagger.v3.oas.models.media.Schema) subtype.getAllOf().get(0)).get$ref(), + "#/components/schemas/AttributeType"); + } + + @Test + public void testSubtypeKeepsCompositionOpenApi31() { + final ModelResolver modelResolver = new ModelResolver(mapper()); + modelResolver.setOpenapi31(true); + final ModelConverterContextImpl context = new ModelConverterContextImpl(modelResolver); + + context.resolve(new AnnotatedType(AttributeType.class)); + final Map models = context.getDefinedModels(); + + final io.swagger.v3.oas.models.media.Schema subtype = models.get("DateAttributeTypeImpl"); + assertNotNull(subtype, "subtype schema should be defined"); + assertNotNull(subtype.getAllOf(), + "subtype must keep its allOf reference to the parent, but was: " + subtype); + assertTrue(subtype.getAllOf().stream() + .anyMatch(s -> ("#/components/schemas/AttributeType") + .equals(((io.swagger.v3.oas.models.media.Schema) s).get$ref())), + "allOf must reference the parent AttributeType, but was: " + subtype.getAllOf()); + } + + interface ResourceType { + } + + @Schema( + subTypes = {DateAttributeType.class}, + discriminatorProperty = "resourceType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DateAttributeType", schema = DateAttributeType.class) + }) + interface AttributeType extends ResourceType { + @Schema(description = "The public id of the attribute type.", example = "ApprovalDate_C") + String getPublicId(); + } + + @Schema(implementation = DateAttributeTypeImpl.class) + interface DateAttributeType extends AttributeType { + } + + static class AttributeTypeImpl implements AttributeType { + @Override + public String getPublicId() { + return ""; + } + } + + static class DateAttributeTypeImpl extends AttributeTypeImpl implements DateAttributeType { + } +}