From 9e2cde139c2779fc2d800fb040edf78a2aafb2bf Mon Sep 17 00:00:00 2001 From: kuntal1461 Date: Sun, 28 Sep 2025 14:30:26 +0530 Subject: [PATCH 1/3] fix(core): follow ApiResponse $ref in SpecFilter to avoid pruning schemas Fixes #4960 --- .../io/swagger/v3/core/filter/SpecFilter.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 d4bf2a9c47..10a2be4343 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 @@ -36,6 +36,7 @@ public class SpecFilter { + private OpenAPI _ctxOpenAPI; public OpenAPI filter(OpenAPI openAPI, OpenAPISpecFilter filter, Map> params, Map cookies, Map> headers) { OpenAPI filteredOpenAPI = filterOpenAPI(filter, openAPI, params, cookies, headers); if (filteredOpenAPI == null) { @@ -399,6 +400,26 @@ private void addPathItemSchemaRef(PathItem pathItem, Set referencedDefin } private void addApiResponseSchemaRef(ApiResponse response, Set referencedDefinitions) { + if (response == null) return; + String respRef = response.get$ref(); + if (respRef != null && !respRef.isEmpty() && _ctxOpenAPI != null) { + String name = (String) RefUtils.extractSimpleName(respRef).getLeft(); + referencedDefinitions.add(name); + Components comps = _ctxOpenAPI.getComponents(); + ApiResponse resolved = (comps != null && comps.getResponses() != null) + ? comps.getResponses().get(name) + : null; + if (resolved != null) { + if (resolved.getHeaders() != null) { + for (Header h : resolved.getHeaders().values()) { + addHeaderSchemaRef(h, referencedDefinitions); + } + } + addContentSchemaRef(resolved.getContent(), referencedDefinitions); + } + return; + } + if (response.getHeaders() != null) { for (String keyHeaders : response.getHeaders().keySet()) { Header header = response.getHeaders().get(keyHeaders); @@ -469,7 +490,7 @@ private void addComponentsSchemaRef(Components components, Set reference } protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) { - + this._ctxOpenAPI = openApi; if (openApi == null || openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { return openApi; } @@ -499,6 +520,7 @@ protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) { .retainAll(referencedDefinitions.stream() .map(s -> (String) RefUtils.extractSimpleName(s).getLeft()) .collect(Collectors.toSet())); + this._ctxOpenAPI = null; return openApi; } From 92688350e40d9193a323aabdbb96a90f41ad5b95 Mon Sep 17 00:00:00 2001 From: kuntal1461 Date: Tue, 7 Oct 2025 19:47:24 +0530 Subject: [PATCH 2/3] By Kuntal : "fix(validation): apply Meta annotations correctly in ModelResolver (#4886)" --- .../v3/core/jackson/ModelResolver.java | 40 ++++++++++ .../ComposedConstraintMetaAnnotationTest.java | 74 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java 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 7c00931b23..7fd58e532e 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 @@ -1787,6 +1787,8 @@ protected boolean applyBeanValidatorAnnotations(Schema property, Annotation[] an return modified; } } + // expand composed constraint meta-annotations (e.g., @Min/@Max on custom annotations) + annotations = expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); if (annotations != null) { for (Annotation anno : annotations) { @@ -1979,6 +1981,8 @@ protected boolean checkGroupValidation(Class[] groups, Set invocationGrou } protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotation[] annotations, Schema parent, boolean applyNotNullAnnotations) { + // expand composed constraint meta-annotations (e.g., @Min/@Max on custom annotations) + annotations = expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); boolean modified = false; if (annotations != null) { @@ -2082,6 +2086,42 @@ protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotat return modified; } + /** + * Expands provided annotations to include bean-validation constraint annotations present as meta-annotations + * on custom annotations (i.e., composed constraints like a custom @ValidStoreId annotated with @Min/@Max). + * Only javax.validation.constraints annotations are added to avoid unrelated meta-annotations. + */ + private 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(); + // include only standard bean validation constraint annotations + if (name != null && name.startsWith("javax.validation.constraints")) { + merged.putIfAbsent(name, meta); + } + } + } + } catch (Throwable t) { + // be conservative: if anything goes wrong, fall back to original annotations + return annotations; + } + return merged.values().toArray(new Annotation[0]); + } + private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConverterContext context, JsonView jsonViewAnnotation) { final List types = _intr().findSubtypes(bean.getClassInfo()); if (types == null) { 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..037904b09f --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ComposedConstraintMetaAnnotationTest.java @@ -0,0 +1,74 @@ +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 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 {}; + } + + static class TestStoreDto { + @Min(0) + @Max(999) + @NotNull + private Short storeId; + + @ValidStoreId + @NotNull + private Short metaStoreId; + + 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; + } + } + + @Test + public void readsComposedConstraintOnDtoField() { + 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"); + // Should carry over min/max from composed constraint + assertEquals(((IntegerSchema) meta).getMinimum().intValue(), 0); + assertEquals(((IntegerSchema) meta).getMaximum().intValue(), 999); + } +} + From cb540da5533851f2816aaa1396d3d4817b7761e6 Mon Sep 17 00:00:00 2001 From: Daniel Kmiecik Date: Fri, 8 May 2026 14:23:04 +0200 Subject: [PATCH 3/3] fix: refactor and centralize validation meta-annotation expansion logic --- .../io/swagger/v3/core/filter/SpecFilter.java | 23 --- .../v3/core/jackson/ModelResolver.java | 42 +---- .../core/util/ValidationAnnotationsUtils.java | 47 +++++ .../ComposedConstraintMetaAnnotationTest.java | 172 ++++++++++++++++-- 4 files changed, 205 insertions(+), 79 deletions(-) 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 dc39e4eaa3..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 @@ -36,7 +36,6 @@ public class SpecFilter { - private OpenAPI _ctxOpenAPI; public OpenAPI filter(OpenAPI openAPI, OpenAPISpecFilter filter, Map> params, Map cookies, Map> headers) { OpenAPI filteredOpenAPI = filterOpenAPI(filter, openAPI, params, cookies, headers); if (filteredOpenAPI == null) { @@ -404,26 +403,6 @@ private void addPathItemSchemaRef(PathItem pathItem, Set referencedDefin } private void addApiResponseSchemaRef(ApiResponse response, Set referencedDefinitions) { - if (response == null) return; - String respRef = response.get$ref(); - if (respRef != null && !respRef.isEmpty() && _ctxOpenAPI != null) { - String name = (String) RefUtils.extractSimpleName(respRef).getLeft(); - referencedDefinitions.add(name); - Components comps = _ctxOpenAPI.getComponents(); - ApiResponse resolved = (comps != null && comps.getResponses() != null) - ? comps.getResponses().get(name) - : null; - if (resolved != null) { - if (resolved.getHeaders() != null) { - for (Header h : resolved.getHeaders().values()) { - addHeaderSchemaRef(h, referencedDefinitions); - } - } - addContentSchemaRef(resolved.getContent(), referencedDefinitions); - } - return; - } - if (response.getHeaders() != null) { for (String keyHeaders : response.getHeaders().keySet()) { Header header = response.getHeaders().get(keyHeaders); @@ -494,7 +473,6 @@ private void addComponentsSchemaRef(Components components, Set reference } protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) { - this._ctxOpenAPI = openApi; if (openApi == null || openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { return openApi; } @@ -524,7 +502,6 @@ protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) { .retainAll(referencedDefinitions.stream() .map(s -> (String) RefUtils.extractSimpleName(s).getLeft()) .collect(Collectors.toSet())); - this._ctxOpenAPI = 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 3a054bf930..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,8 +1780,7 @@ protected boolean applyBeanValidatorAnnotations(Schema property, Annotation[] an return modified; } } - // expand composed constraint meta-annotations (e.g., @Min/@Max on custom annotations) - annotations = expandValidationMetaAnnotations(annotations); + annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); if (annotations != null) { for (Annotation anno : annotations) { @@ -1952,8 +1951,7 @@ protected boolean checkGroupValidation(Class[] groups, Set invocationGrou } protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotation[] annotations, Schema parent, boolean applyNotNullAnnotations) { - // expand composed constraint meta-annotations (e.g., @Min/@Max on custom annotations) - annotations = expandValidationMetaAnnotations(annotations); + annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations); Map annos = new HashMap<>(); boolean modified = false; if (annotations != null) { @@ -2012,42 +2010,6 @@ protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotat return modified; } - /** - * Expands provided annotations to include bean-validation constraint annotations present as meta-annotations - * on custom annotations (i.e., composed constraints like a custom @ValidStoreId annotated with @Min/@Max). - * Only javax.validation.constraints annotations are added to avoid unrelated meta-annotations. - */ - private 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(); - // include only standard bean validation constraint annotations - if (name != null && name.startsWith("javax.validation.constraints")) { - merged.putIfAbsent(name, meta); - } - } - } - } catch (Throwable t) { - // be conservative: if anything goes wrong, fall back to original annotations - return annotations; - } - return merged.values().toArray(new Annotation[0]); - } - private boolean resolveSubtypes(Schema model, BeanDescription bean, ModelConverterContext context, JsonView jsonViewAnnotation) { final List types = _intr().findSubtypes(bean.getClassInfo()); if (types == 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 index 037904b09f..d81adb6f38 100644 --- 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 @@ -6,10 +6,13 @@ 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; @@ -23,7 +26,7 @@ public class ComposedConstraintMetaAnnotationTest { @Min(0) @Max(999) - @Target({ElementType.FIELD, ElementType.PARAMETER}) + @Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {}) public @interface ValidStoreId { @@ -32,6 +35,60 @@ public class ComposedConstraintMetaAnnotationTest { 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) @@ -42,33 +99,116 @@ static class TestStoreDto { @NotNull private Short metaStoreId; - public Short getStoreId() { - return storeId; - } + @ValidName + private String name; - public void setStoreId(Short storeId) { - this.storeId = storeId; - } + @ValidEmail + private String email; - public Short getMetaStoreId() { - return metaStoreId; - } + @ValidRange(min = 5, max = 50) + private Short rangeField; - public void setMetaStoreId(Short metaStoreId) { - this.metaStoreId = metaStoreId; - } + @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 readsComposedConstraintOnDtoField() { + 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"); - // Should carry over min/max from composed constraint 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"); + } +}