diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/filter/SpecFilter.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/filter/SpecFilter.java index f7ebfbea7f..5ca25e108f 100755 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/filter/SpecFilter.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/filter/SpecFilter.java @@ -473,7 +473,6 @@ private void addComponentsSchemaRef(Components components, Set reference } protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) { - if (openApi == null || openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { return openApi; } diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java index 61b3780038..f25f99a654 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java @@ -1780,6 +1780,7 @@ protected boolean applyBeanValidatorAnnotations(Schema property, Annotation[] an return modified; } } + annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); if (annotations != null) { for (Annotation anno : annotations) { @@ -1950,6 +1951,7 @@ protected boolean checkGroupValidation(Class[] groups, Set invocationGrou } protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotation[] annotations, Schema parent, boolean applyNotNullAnnotations) { + annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); boolean modified = false; if (annotations != null) { diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/ValidationAnnotationsUtils.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/ValidationAnnotationsUtils.java index 468349daf0..c73de63995 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/ValidationAnnotationsUtils.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/ValidationAnnotationsUtils.java @@ -3,7 +3,10 @@ import io.swagger.v3.oas.models.media.Schema; import javax.validation.constraints.*; +import java.lang.annotation.Annotation; import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; import static io.swagger.v3.core.util.SchemaTypeUtils.*; @@ -220,6 +223,40 @@ public static boolean applyNegativeConstraint(Schema schema) { return false; } + /** + * Expands annotations to include bean-validation constraint annotations present as meta-annotations + * on custom composed constraints (e.g. a custom {@code @ValidStoreId} annotated with {@code @Min}/{@code @Max}). + * Direct annotations take priority over meta-annotations (putIfAbsent). + */ + public static Annotation[] expandValidationMetaAnnotations(Annotation[] annotations) { + if (annotations == null || annotations.length == 0) { + return annotations; + } + Map merged = new LinkedHashMap<>(); + for (Annotation a : annotations) { + if (a != null) { + merged.put(a.annotationType().getName(), a); + } + } + try { + for (Annotation a : annotations) { + if (a == null) continue; + Annotation[] metas = a.annotationType().getAnnotations(); + if (metas == null) continue; + for (Annotation meta : metas) { + if (meta == null) continue; + String name = meta.annotationType().getName(); + if (name != null && name.startsWith("javax.validation.constraints")) { + merged.putIfAbsent(name, meta); + } + } + } + } catch (Throwable t) { + return annotations; + } + return merged.values().toArray(new Annotation[0]); + } + public static boolean applyNegativeOrZeroConstraint(Schema schema) { if (isNumberSchema(schema)) { BigDecimal current = schema.getMaximum(); diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java new file mode 100644 index 0000000000..0cb5a97bcd --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java @@ -0,0 +1,122 @@ +package io.swagger.v3.core.resolving; + +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.Schema; +import org.testng.annotations.Test; + +import javax.validation.Constraint; +import javax.validation.Payload; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +public class ComposedConstraintMetaAnnotationTest { + + @Min(0) + @Max(999) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {}) + public @interface ValidStoreId { + String message() default "Invalid store ID"; + Class[] groups() default {}; + Class[] payload() default {}; + } + + @Size(min = 1, max = 50) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {}) + public @interface ValidName { + String message() default "Invalid name"; + Class[] groups() default {}; + Class[] payload() default {}; + } + + @Pattern(regexp = "^[a-z0-9._%+\\-]+@[a-z0-9.\\-]+\\.[a-z]{2,}$") + @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {}) + public @interface ValidEmail { + String message() default "Invalid email"; + Class[] groups() default {}; + Class[] payload() default {}; + } + + static class TestStoreDto { + @Min(0) + @Max(999) + @NotNull + private Short storeId; + + @ValidStoreId + @NotNull + private Short metaStoreId; + + @ValidName + private String name; + + @ValidEmail + private String email; + + public Short getStoreId() { return storeId; } + public void setStoreId(Short storeId) { this.storeId = storeId; } + public Short getMetaStoreId() { return metaStoreId; } + public void setMetaStoreId(Short metaStoreId) { this.metaStoreId = metaStoreId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + } + + @Test + public void readsComposedMinMaxConstraintOnDtoField() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + assertNotNull(model, "Model should be resolved"); + Schema meta = (Schema) model.getProperties().get("metaStoreId"); + assertNotNull(meta, "metaStoreId property should exist"); + assertEquals(((IntegerSchema) meta).getMinimum().intValue(), 0); + assertEquals(((IntegerSchema) meta).getMaximum().intValue(), 999); + } + + @Test + public void dtoFieldParityWithDirectAnnotations() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema direct = (Schema) model.getProperties().get("storeId"); + Schema meta = (Schema) model.getProperties().get("metaStoreId"); + assertEquals(meta.getMinimum(), direct.getMinimum(), "minimum should match direct annotation"); + assertEquals(meta.getMaximum(), direct.getMaximum(), "maximum should match direct annotation"); + } + + @Test + public void readsComposedSizeConstraintOnDtoField() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema name = (Schema) model.getProperties().get("name"); + assertNotNull(name, "name property should exist"); + assertEquals((int) name.getMinLength(), 1); + assertEquals((int) name.getMaxLength(), 50); + } + + @Test + public void readsComposedPatternConstraintOnDtoField() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema email = (Schema) model.getProperties().get("email"); + assertNotNull(email, "email property should exist"); + assertNotNull(email.getPattern(), "pattern should be set"); + } +}