Skip to content

Commit 7ae37cb

Browse files
authored
fix: resolve Validation Meta annotations not working (#4886) (#4986)
1 parent 5239cae commit 7ae37cb

4 files changed

Lines changed: 263 additions & 1 deletion

File tree

modules/swagger-core/src/main/java/io/swagger/v3/core/filter/SpecFilter.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,6 @@ private void addComponentsSchemaRef(Components components, Set<String> reference
473473
}
474474

475475
protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) {
476-
477476
if (openApi == null || openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) {
478477
return openApi;
479478
}

modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,7 @@ protected boolean applyBeanValidatorAnnotations(Schema property, Annotation[] an
17801780
return modified;
17811781
}
17821782
}
1783+
annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations);
17831784
Map<String, Annotation> annos = new HashMap<>();
17841785
if (annotations != null) {
17851786
for (Annotation anno : annotations) {
@@ -1950,6 +1951,7 @@ protected boolean checkGroupValidation(Class[] groups, Set<Class> invocationGrou
19501951
}
19511952

19521953
protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotation[] annotations, Schema parent, boolean applyNotNullAnnotations) {
1954+
annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations);
19531955
Map<String, Annotation> annos = new HashMap<>();
19541956
boolean modified = false;
19551957
if (annotations != null) {

modules/swagger-core/src/main/java/io/swagger/v3/core/util/ValidationAnnotationsUtils.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
import io.swagger.v3.oas.models.media.Schema;
44

55
import javax.validation.constraints.*;
6+
import java.lang.annotation.Annotation;
67
import java.math.BigDecimal;
8+
import java.util.ArrayDeque;
9+
import java.util.HashSet;
10+
import java.util.LinkedHashMap;
11+
import java.util.Map;
12+
import java.util.Queue;
13+
import java.util.Set;
714

815
import static io.swagger.v3.core.util.SchemaTypeUtils.*;
916

@@ -220,6 +227,46 @@ public static boolean applyNegativeConstraint(Schema schema) {
220227
return false;
221228
}
222229

230+
/**
231+
* Expands annotations to include bean-validation constraint annotations present as meta-annotations
232+
* on custom composed constraints (e.g. a custom {@code @ValidStoreId} annotated with {@code @Min}/{@code @Max}).
233+
* Direct annotations take priority over meta-annotations (putIfAbsent).
234+
*/
235+
public static Annotation[] expandValidationMetaAnnotations(Annotation[] annotations) {
236+
if (annotations == null || annotations.length == 0) {
237+
return annotations;
238+
}
239+
Map<String, Annotation> merged = new LinkedHashMap<>();
240+
for (Annotation a : annotations) {
241+
if (a != null) {
242+
merged.put(a.annotationType().getName(), a);
243+
}
244+
}
245+
try {
246+
Set<Class<?>> visited = new HashSet<>();
247+
Queue<Annotation> queue = new ArrayDeque<>();
248+
for (Annotation a : annotations) {
249+
if (a != null) queue.add(a);
250+
}
251+
while (!queue.isEmpty()) {
252+
Annotation a = queue.poll();
253+
if (!visited.add(a.annotationType())) continue;
254+
for (Annotation meta : a.annotationType().getAnnotations()) {
255+
if (meta == null) continue;
256+
String name = meta.annotationType().getName();
257+
if (name.startsWith("javax.validation.constraints")) {
258+
merged.putIfAbsent(name, meta);
259+
} else {
260+
queue.add(meta);
261+
}
262+
}
263+
}
264+
} catch (Throwable t) {
265+
return annotations;
266+
}
267+
return merged.values().toArray(new Annotation[0]);
268+
}
269+
223270
public static boolean applyNegativeOrZeroConstraint(Schema schema) {
224271
if (isNumberSchema(schema)) {
225272
BigDecimal current = schema.getMaximum();
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package io.swagger.v3.core.resolving;
2+
3+
import io.swagger.v3.core.converter.ModelConverters;
4+
import io.swagger.v3.oas.models.media.IntegerSchema;
5+
import io.swagger.v3.oas.models.media.Schema;
6+
import org.testng.annotations.Test;
7+
8+
import javax.validation.Constraint;
9+
import javax.validation.OverridesAttribute;
10+
import javax.validation.Payload;
11+
import javax.validation.constraints.Max;
12+
import javax.validation.constraints.Min;
13+
import javax.validation.constraints.NotNull;
14+
import javax.validation.constraints.Pattern;
15+
import javax.validation.constraints.Size;
16+
import java.lang.annotation.ElementType;
17+
import java.lang.annotation.Retention;
18+
import java.lang.annotation.RetentionPolicy;
19+
import java.lang.annotation.Target;
20+
import java.util.Map;
21+
22+
import static org.testng.Assert.assertEquals;
23+
import static org.testng.Assert.assertNotNull;
24+
25+
public class ComposedConstraintMetaAnnotationTest {
26+
27+
@Min(0)
28+
@Max(999)
29+
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
30+
@Retention(RetentionPolicy.RUNTIME)
31+
@Constraint(validatedBy = {})
32+
public @interface ValidStoreId {
33+
String message() default "Invalid store ID";
34+
Class<?>[] groups() default {};
35+
Class<? extends Payload>[] payload() default {};
36+
}
37+
38+
@Size(min = 1, max = 50)
39+
@Target({ElementType.FIELD, ElementType.PARAMETER})
40+
@Retention(RetentionPolicy.RUNTIME)
41+
@Constraint(validatedBy = {})
42+
public @interface ValidName {
43+
String message() default "Invalid name";
44+
Class<?>[] groups() default {};
45+
Class<? extends Payload>[] payload() default {};
46+
}
47+
48+
@Pattern(regexp = "^[a-z0-9._%+\\-]+@[a-z0-9.\\-]+\\.[a-z]{2,}$")
49+
@Target({ElementType.FIELD, ElementType.PARAMETER})
50+
@Retention(RetentionPolicy.RUNTIME)
51+
@Constraint(validatedBy = {})
52+
public @interface ValidEmail {
53+
String message() default "Invalid email";
54+
Class<?>[] groups() default {};
55+
Class<? extends Payload>[] payload() default {};
56+
}
57+
58+
/**
59+
* Mimics how Hibernate Validator's @Range works: meta-annotations @Min/@Max carry default
60+
* values, while the actual per-use values are meant to be applied via @OverridesAttribute.
61+
* Our implementation reads meta-annotations from the annotation *type definition*, so it
62+
* always sees the defaults (min=0, max=Long.MAX_VALUE) — not whatever the caller passes
63+
* as @ValidRange(min=5, max=50). This is a known limitation documented by the test below.
64+
*/
65+
@Min(0)
66+
@Max(Long.MAX_VALUE)
67+
@Target({ElementType.FIELD, ElementType.PARAMETER})
68+
@Retention(RetentionPolicy.RUNTIME)
69+
@Constraint(validatedBy = {})
70+
public @interface ValidRange {
71+
@OverridesAttribute(constraint = Min.class, name = "value")
72+
long min() default 0;
73+
74+
@OverridesAttribute(constraint = Max.class, name = "value")
75+
long max() default Long.MAX_VALUE;
76+
77+
String message() default "Out of range";
78+
Class<?>[] groups() default {};
79+
Class<? extends Payload>[] payload() default {};
80+
}
81+
82+
@ValidStoreId
83+
@Target({ElementType.FIELD, ElementType.PARAMETER})
84+
@Retention(RetentionPolicy.RUNTIME)
85+
@Constraint(validatedBy = {})
86+
public @interface ValidStoreIdNested {
87+
String message() default "Invalid store ID (nested)";
88+
Class<?>[] groups() default {};
89+
Class<? extends Payload>[] payload() default {};
90+
}
91+
92+
static class TestStoreDto {
93+
@Min(0)
94+
@Max(999)
95+
@NotNull
96+
private Short storeId;
97+
98+
@ValidStoreId
99+
@NotNull
100+
private Short metaStoreId;
101+
102+
@ValidName
103+
private String name;
104+
105+
@ValidEmail
106+
private String email;
107+
108+
@ValidRange(min = 5, max = 50)
109+
private Short rangeField;
110+
111+
@ValidStoreIdNested
112+
private Short nestedStoreId;
113+
114+
@ValidStoreId
115+
@Max(100)
116+
private Short priorityStoreId;
117+
118+
public Short getStoreId() { return storeId; }
119+
public void setStoreId(Short storeId) { this.storeId = storeId; }
120+
public Short getMetaStoreId() { return metaStoreId; }
121+
public void setMetaStoreId(Short metaStoreId) { this.metaStoreId = metaStoreId; }
122+
public String getName() { return name; }
123+
public void setName(String name) { this.name = name; }
124+
public String getEmail() { return email; }
125+
public void setEmail(String email) { this.email = email; }
126+
public Short getRangeField() { return rangeField; }
127+
public void setRangeField(Short rangeField) { this.rangeField = rangeField; }
128+
public Short getNestedStoreId() { return nestedStoreId; }
129+
public void setNestedStoreId(Short nestedStoreId) { this.nestedStoreId = nestedStoreId; }
130+
public Short getPriorityStoreId() { return priorityStoreId; }
131+
public void setPriorityStoreId(Short priorityStoreId) { this.priorityStoreId = priorityStoreId; }
132+
}
133+
134+
@Test
135+
public void readsComposedMinMaxConstraintOnDtoField() {
136+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
137+
Schema model = schemas.get("TestStoreDto");
138+
assertNotNull(model, "Model should be resolved");
139+
Schema meta = (Schema) model.getProperties().get("metaStoreId");
140+
assertNotNull(meta, "metaStoreId property should exist");
141+
assertEquals(((IntegerSchema) meta).getMinimum().intValue(), 0);
142+
assertEquals(((IntegerSchema) meta).getMaximum().intValue(), 999);
143+
}
144+
145+
@Test
146+
public void dtoFieldParityWithDirectAnnotations() {
147+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
148+
Schema model = schemas.get("TestStoreDto");
149+
Schema direct = (Schema) model.getProperties().get("storeId");
150+
Schema meta = (Schema) model.getProperties().get("metaStoreId");
151+
assertEquals(meta.getMinimum(), direct.getMinimum(), "minimum should match direct annotation");
152+
assertEquals(meta.getMaximum(), direct.getMaximum(), "maximum should match direct annotation");
153+
}
154+
155+
@Test
156+
public void readsComposedSizeConstraintOnDtoField() {
157+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
158+
Schema model = schemas.get("TestStoreDto");
159+
Schema name = (Schema) model.getProperties().get("name");
160+
assertNotNull(name, "name property should exist");
161+
assertEquals((int) name.getMinLength(), 1);
162+
assertEquals((int) name.getMaxLength(), 50);
163+
}
164+
165+
@Test
166+
public void readsComposedPatternConstraintOnDtoField() {
167+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
168+
Schema model = schemas.get("TestStoreDto");
169+
Schema email = (Schema) model.getProperties().get("email");
170+
assertNotNull(email, "email property should exist");
171+
assertNotNull(email.getPattern(), "pattern should be set");
172+
}
173+
174+
@Test
175+
public void readsDeepNestedComposedConstraintOnDtoField() {
176+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
177+
Schema model = schemas.get("TestStoreDto");
178+
Schema nested = (Schema) model.getProperties().get("nestedStoreId");
179+
assertNotNull(nested, "nestedStoreId property should exist");
180+
assertEquals(((IntegerSchema) nested).getMinimum().intValue(), 0);
181+
assertEquals(((IntegerSchema) nested).getMaximum().intValue(), 999);
182+
}
183+
184+
@Test
185+
public void directAnnotationTakesPriorityOverMetaAnnotation() {
186+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
187+
Schema model = schemas.get("TestStoreDto");
188+
Schema priority = (Schema) model.getProperties().get("priorityStoreId");
189+
assertNotNull(priority, "priorityStoreId property should exist");
190+
assertEquals(((IntegerSchema) priority).getMinimum().intValue(), 0, "min from @ValidStoreId meta should be set");
191+
assertEquals(((IntegerSchema) priority).getMaximum().intValue(), 100, "direct @Max(100) should win over @ValidStoreId's @Max(999)");
192+
}
193+
194+
/**
195+
* Documents a known limitation: for @Range-style constraints that rely on @OverridesAttribute
196+
* to propagate per-use values (e.g. @ValidRange(min=5, max=50)) into their meta-annotations
197+
* (@Min/@Max), our implementation reads constraints from the annotation *type definition*
198+
* and therefore always sees the default values (min=0, max=Long.MAX_VALUE), not the
199+
* caller-supplied ones. Handling @OverridesAttribute is not yet supported.
200+
*/
201+
@Test
202+
public void rangeStyleConstraintUsesDefaultsNotOverriddenValues() {
203+
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(TestStoreDto.class);
204+
Schema model = schemas.get("TestStoreDto");
205+
Schema range = (Schema) model.getProperties().get("rangeField");
206+
assertNotNull(range, "rangeField property should exist");
207+
// We pick up the *default* values from @Min(0) and @Max(Long.MAX_VALUE) on the type
208+
// definition of @ValidRange, NOT the caller-supplied @ValidRange(min=5, max=50).
209+
assertEquals(range.getMinimum().longValue(), 0L,
210+
"expected default @Min(0) from type definition, not overridden min=5");
211+
assertEquals(range.getMaximum().longValue(), Long.MAX_VALUE,
212+
"expected default @Max(Long.MAX_VALUE) from type definition, not overridden max=50");
213+
}
214+
}

0 commit comments

Comments
 (0)