Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class AnnotatedType {
private boolean skipSchemaName;
private boolean skipJsonIdentity;
private String propertyName;
private boolean isSubtype;

private Components components;

Expand Down Expand Up @@ -243,6 +244,19 @@ public AnnotatedType propertyName(String propertyName) {
return this;
}

public boolean isSubtype() {
return isSubtype;
}

public void setSubtype(boolean isSubtype) {
this.isSubtype = isSubtype;
}

public AnnotatedType subtype(boolean isSubtype) {
this.isSubtype = isSubtype;
return this;
}

private List<Annotation> getProcessedAnnotations(Annotation[] annotations) {
if (annotations == null || annotations.length == 0) {
return new ArrayList<>();
Expand All @@ -264,14 +278,17 @@ public boolean equals(Object o) {
List<Annotation> thisAnnotatinons = getProcessedAnnotations(this.ctxAnnotations);
List<Annotation> thatAnnotatinons = getProcessedAnnotations(that.ctxAnnotations);
return includePropertiesWithoutJSONView == that.includePropertiesWithoutJSONView &&
schemaProperty == that.schemaProperty &&
isSubtype == that.isSubtype &&
Objects.equals(type, that.type) &&
Objects.equals(thisAnnotatinons, thatAnnotatinons) &&
Objects.equals(jsonViewAnnotation, that.jsonViewAnnotation);
Objects.equals(jsonViewAnnotation, that.jsonViewAnnotation) &&
(!schemaProperty || Objects.equals(propertyName, that.propertyName));
}

@Override
public int hashCode() {
List<Annotation> processedAnnotations = getProcessedAnnotations(this.ctxAnnotations);
return Objects.hash(type, jsonViewAnnotation, includePropertiesWithoutJSONView, processedAnnotations);
return Objects.hash(type, jsonViewAnnotation, includePropertiesWithoutJSONView, processedAnnotations, schemaProperty, isSubtype, schemaProperty ? propertyName : null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ public Schema resolve(AnnotatedType annotatedType, ModelConverterContext context
.resolveAsRef(annotatedType.isResolveAsRef())
.jsonViewAnnotation(annotatedType.getJsonViewAnnotation())
.skipSchemaName(true)
.subtype(annotatedType.isSubtype())
.schemaProperty(true)
.components(annotatedType.getComponents())
.propertyName(propName)
Expand Down Expand Up @@ -1560,6 +1561,7 @@ protected Schema processAsId(String propertyName, AnnotatedType type,
.jsonViewAnnotation(type.getJsonViewAnnotation())
.schemaProperty(true)
.components(type.getComponents())
.subtype(type.isSubtype())
.propertyName(type.getPropertyName());

return context.resolve(aType);
Expand Down Expand Up @@ -2110,8 +2112,10 @@ private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConvert
continue;
}

final Schema subtypeModel = context.resolve(new AnnotatedType().type(subtypeType)
.jsonViewAnnotation(jsonViewAnnotation));
final Schema subtypeModel = context.resolve(new AnnotatedType()
.type(subtypeType)
.jsonViewAnnotation(jsonViewAnnotation)
.subtype(true));

if (StringUtils.isBlank(subtypeModel.getName()) ||
subtypeModel.getName().equals(model.getName())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,50 @@ class SubAnnotatedType extends AnnotatedType {
assertEquals(parent.hashCode(), child.hashCode(), "Parent and child hash codes should be equal if their properties are the same.");
assertNotEquals(parent, differentParent, "Objects with different properties should not be equal.");
}

@Test
public void testEquals_shouldDifferentiatePropertyAndSubtypeContexts() {
AnnotatedType typeAsProperty = new AnnotatedType(String.class)
.schemaProperty(false)
.propertyName("fieldA");

AnnotatedType typeAsSubtype = new AnnotatedType(String.class)
.schemaProperty(true)
.propertyName(null);

assertNotEquals(typeAsProperty, typeAsSubtype,
"Objects with different schemaProperty flags must not be equal.");
assertNotEquals(typeAsProperty.hashCode(), typeAsSubtype.hashCode(),
"Hash codes must be different if schemaProperty flags differ.");
}

@Test
public void testEquals_shouldComparePropertyNameWhenSchemaPropertyIsTrue() {
AnnotatedType complexPropA = new AnnotatedType(String.class)
.schemaProperty(true)
.propertyName("fieldA");
AnnotatedType complexPropB = new AnnotatedType(String.class)
.schemaProperty(true)
.propertyName("fieldB");

assertNotEquals(complexPropA, complexPropB,
"When schemaProperty is true, objects with different propertyNames must not be equal.");
assertNotEquals(complexPropA.hashCode(), complexPropB.hashCode(),
"When schemaProperty is true, hash codes must be different if propertyNames differ.");
}

@Test
public void testEquals_shouldBeEqualWhenSchemaPropertyIsTrueAndNamesMatch() {
AnnotatedType complexPropA = new AnnotatedType(String.class)
.schemaProperty(true)
.propertyName("fieldA");
AnnotatedType complexPropC = new AnnotatedType(String.class)
.schemaProperty(true)
.propertyName("fieldA");

assertEquals(complexPropA, complexPropC,
"When schemaProperty is true, objects with the same propertyName must be equal.");
assertEquals(complexPropA.hashCode(), complexPropC.hashCode(),
"When schemaProperty is true, hash codes must be equal if propertyNames are the same.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package io.swagger.v3.core.converting;

import com.fasterxml.jackson.databind.ObjectMapper;
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.Schema;
import io.swagger.v3.oas.models.media.ComposedSchema;
import org.testng.annotations.Test;

import javax.validation.constraints.NotNull;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;

import static org.testng.Assert.*;
import static org.testng.AssertJUnit.assertEquals;

public class PolymorphicSubtypePropertyBleedTest {

@Schema(
description = "base",
discriminatorProperty = "kind",
oneOf = { ConcretionA.class }
)
public static class BaseResponse {
public String sharedField;
public String kind;
}

@Schema(description = "subtype A")
public static class ConcretionA extends BaseResponse {
public String onlyInA;
}

public static class Wrapper {
@NotNull
public ConcretionA concretionA;
}

/**
* Mimics how swagger-core resolves a bean property:
* - schemaProperty=true
* - propertyName set
* - ctxAnnotations from the backing field (e.g. @NotNull)
*/
private io.swagger.v3.oas.models.media.Schema resolveAsBeanProperty(
Class<?> clazz,
String propertyName,
Field backingField,
ModelConverterContextImpl ctx,
ModelResolver resolver
) {
AnnotatedType at = new AnnotatedType()
.type(clazz)
.schemaProperty(true)
.propertyName(propertyName)
.ctxAnnotations(backingField.getAnnotations());
return ctx.resolve(at);
}

/**
* Mimics how resolveSubtypes(...) resolves a polymorphic subtype:
* - NO schemaProperty(true)
* - NO propertyName
* - NO ctxAnnotations from some containing field

*/
private io.swagger.v3.oas.models.media.Schema resolveAsPolymorphicSubtype(
Class<?> clazz,
ModelConverterContextImpl ctx,
ModelResolver resolver
) {
AnnotatedType at = new AnnotatedType()
.type(clazz);
return ctx.resolve(at);
}

/**
* Baseline for comparison: new context, clean standalone schema resolution.
*/
private io.swagger.v3.oas.models.media.Schema resolveAsStandaloneSchema(
Class<?> clazz,
ModelResolver resolver
) {
ModelConverterContextImpl freshCtx = new ModelConverterContextImpl(resolver);
AnnotatedType at = new AnnotatedType()
.type(clazz);
return freshCtx.resolve(at);
}

@Test
public void subtypeResolution_shouldMatchStandalone_andNotBleedFromPropertyContext() throws Exception {
ObjectMapper mapper = new ObjectMapper();
ModelResolver resolver = new ModelResolver(mapper);

ModelConverterContextImpl sharedCtx = new ModelConverterContextImpl(resolver);

// 1. Resolve ConcretionA in the bean property context
Field wrapperField = Wrapper.class.getDeclaredField("concretionA");
io.swagger.v3.oas.models.media.Schema concretionAsPropertySchema =
resolveAsBeanProperty(
ConcretionA.class,
"concretionA",
wrapperField,
sharedCtx,
resolver
);

assertNotNull(concretionAsPropertySchema, "bean property resolution returned null");

// 2. Resolve ConcretionA again, but this time as a polymorphic subtype using the SAME sharedCtx
io.swagger.v3.oas.models.media.Schema concretionAsPolymorphicSchema =
resolveAsPolymorphicSubtype(
ConcretionA.class,
sharedCtx,
resolver
);
assertNotNull(concretionAsPolymorphicSchema, "polymorphic subtype resolution returned null");

// 3. Resolve ConcretionA in a clean context to represent the canonical standalone subtype schema.
io.swagger.v3.oas.models.media.Schema concretionStandaloneSchema =
resolveAsStandaloneSchema(
ConcretionA.class,
resolver
);
assertNotNull(concretionStandaloneSchema, "standalone subtype resolution returned null");

// nullable should be consistent between standalone and subtype
assertEquals(
concretionStandaloneSchema.getNullable(),
concretionAsPolymorphicSchema.getNullable()
);

// required list should match between standalone and subtype-in-sharedCtx
List<?> requiredStandalone = concretionStandaloneSchema.getRequired();
List<?> requiredPolymorphic = concretionAsPolymorphicSchema.getRequired();
if (requiredStandalone == null) requiredStandalone = java.util.Collections.emptyList();
if (requiredPolymorphic == null) requiredPolymorphic = java.util.Collections.emptyList();
assertEquals(
requiredStandalone,
requiredPolymorphic
);

// Name should match standalone. We don't want subtype schemas
assertEquals(
concretionStandaloneSchema.getName(),
concretionAsPolymorphicSchema.getName()
);

//Properties should still include subtype-specific fields like 'onlyInA'.
Map<?, ?> propsStandalone = concretionStandaloneSchema.getProperties();
Map<?, ?> propsPolymorphic = concretionAsPolymorphicSchema.getProperties();
assertNotNull(propsPolymorphic);
assertTrue(propsPolymorphic.containsKey("onlyInA"));
assertEquals(
propsStandalone == null
? java.util.Collections.emptySet()
: propsStandalone.keySet(),
propsPolymorphic.keySet()
);

assertNotSame(
concretionAsPropertySchema,
concretionAsPolymorphicSchema
);
}


@Test
public void polymorphicSubtype_mustContainSubtypeFragment_notJustBaseRef() {
ObjectMapper mapper = new ObjectMapper();
ModelResolver resolver = new ModelResolver(mapper);
ModelConverterContextImpl ctx = new ModelConverterContextImpl(resolver);

AnnotatedType subtypeAT = new AnnotatedType()
.type(ConcretionA.class)
.schemaProperty(true);

io.swagger.v3.oas.models.media.Schema resolvedSubtypeSchema = ctx.resolve(subtypeAT);

assertTrue(resolvedSubtypeSchema.getProperties().containsKey("onlyInA"));
}
}
Loading