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..d490043ea0 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,14 @@ import io.swagger.v3.oas.models.media.Schema; import javax.validation.constraints.*; +import java.lang.annotation.Annotation; import java.math.BigDecimal; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Queue; +import java.util.Set; import static io.swagger.v3.core.util.SchemaTypeUtils.*; @@ -220,6 +227,46 @@ 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 { + Set> visited = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + for (Annotation a : annotations) { + if (a != null) queue.add(a); + } + while (!queue.isEmpty()) { + Annotation a = queue.poll(); + if (!visited.add(a.annotationType())) continue; + for (Annotation meta : a.annotationType().getAnnotations()) { + if (meta == null) continue; + String name = meta.annotationType().getName(); + if (name.startsWith("javax.validation.constraints")) { + merged.putIfAbsent(name, meta); + } else { + queue.add(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..d81adb6f38 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java @@ -0,0 +1,214 @@ +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.OverridesAttribute; +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, ElementType.ANNOTATION_TYPE}) + @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 {}; + } + + /** + * Mimics how Hibernate Validator's @Range works: meta-annotations @Min/@Max carry default + * values, while the actual per-use values are meant to be applied via @OverridesAttribute. + * Our implementation reads meta-annotations from the annotation *type definition*, so it + * always sees the defaults (min=0, max=Long.MAX_VALUE) — not whatever the caller passes + * as @ValidRange(min=5, max=50). This is a known limitation documented by the test below. + */ + @Min(0) + @Max(Long.MAX_VALUE) + @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {}) + public @interface ValidRange { + @OverridesAttribute(constraint = Min.class, name = "value") + long min() default 0; + + @OverridesAttribute(constraint = Max.class, name = "value") + long max() default Long.MAX_VALUE; + + String message() default "Out of range"; + Class[] groups() default {}; + Class[] payload() default {}; + } + + @ValidStoreId + @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @Constraint(validatedBy = {}) + public @interface ValidStoreIdNested { + String message() default "Invalid store ID (nested)"; + 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; + + @ValidRange(min = 5, max = 50) + private Short rangeField; + + @ValidStoreIdNested + private Short nestedStoreId; + + @ValidStoreId + @Max(100) + private Short priorityStoreId; + + 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; } + public Short getRangeField() { return rangeField; } + public void setRangeField(Short rangeField) { this.rangeField = rangeField; } + public Short getNestedStoreId() { return nestedStoreId; } + public void setNestedStoreId(Short nestedStoreId) { this.nestedStoreId = nestedStoreId; } + public Short getPriorityStoreId() { return priorityStoreId; } + public void setPriorityStoreId(Short priorityStoreId) { this.priorityStoreId = priorityStoreId; } + } + + @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"); + } + + @Test + public void readsDeepNestedComposedConstraintOnDtoField() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema nested = (Schema) model.getProperties().get("nestedStoreId"); + assertNotNull(nested, "nestedStoreId property should exist"); + assertEquals(((IntegerSchema) nested).getMinimum().intValue(), 0); + assertEquals(((IntegerSchema) nested).getMaximum().intValue(), 999); + } + + @Test + public void directAnnotationTakesPriorityOverMetaAnnotation() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema priority = (Schema) model.getProperties().get("priorityStoreId"); + assertNotNull(priority, "priorityStoreId property should exist"); + assertEquals(((IntegerSchema) priority).getMinimum().intValue(), 0, "min from @ValidStoreId meta should be set"); + assertEquals(((IntegerSchema) priority).getMaximum().intValue(), 100, "direct @Max(100) should win over @ValidStoreId's @Max(999)"); + } + + /** + * Documents a known limitation: for @Range-style constraints that rely on @OverridesAttribute + * to propagate per-use values (e.g. @ValidRange(min=5, max=50)) into their meta-annotations + * (@Min/@Max), our implementation reads constraints from the annotation *type definition* + * and therefore always sees the default values (min=0, max=Long.MAX_VALUE), not the + * caller-supplied ones. Handling @OverridesAttribute is not yet supported. + */ + @Test + public void rangeStyleConstraintUsesDefaultsNotOverriddenValues() { + Map schemas = ModelConverters.getInstance().readAll(TestStoreDto.class); + Schema model = schemas.get("TestStoreDto"); + Schema range = (Schema) model.getProperties().get("rangeField"); + assertNotNull(range, "rangeField property should exist"); + // We pick up the *default* values from @Min(0) and @Max(Long.MAX_VALUE) on the type + // definition of @ValidRange, NOT the caller-supplied @ValidRange(min=5, max=50). + assertEquals(range.getMinimum().longValue(), 0L, + "expected default @Min(0) from type definition, not overridden min=5"); + assertEquals(range.getMaximum().longValue(), Long.MAX_VALUE, + "expected default @Max(Long.MAX_VALUE) from type definition, not overridden max=50"); + } +}