ComposedConstraintMetaAnnotationTest.java
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.*;
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.*;
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<? extends Payload>[] 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<? extends Payload>[] 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<? extends Payload>[] payload() default {};
}
/**
* Mimics how Hibernate Validator's {@code @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 {@code @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<? extends Payload>[] payload() default {};
}
@Min(4)
@Max(Long.MAX_VALUE)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface FourOrMore {
@OverridesAttribute(constraint = Max.class, name = "value")
long max() default Long.MAX_VALUE;
String message() default "Out of range";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Min(0)
@Max(Long.MAX_VALUE)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface RangeWithOverridesAttributeList {
@OverridesAttribute.List({
@OverridesAttribute(constraint = Min.class, name = "value"),
@OverridesAttribute(constraint = Max.class, name = "value")
})
long value() default 0;
String message() default "Out of range";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Min(0)
@Max(Long.MAX_VALUE)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface RangeWithRepeatedOverridesAttribute {
@OverridesAttribute(constraint = Min.class, name = "value")
@OverridesAttribute(constraint = Max.class, name = "value")
long value() default 0;
String message() default "Out of range";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Size(min = 2, max = 100)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface LimitedText {
@OverridesAttribute(constraint = Size.class, name = "max")
int max() default 100;
String message() default "Invalid text";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@FourOrMore(max = 10)
@Max(value = 10)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface ComposedFourOrMoreWithFixedMax {
String message() default "Out of range";
Class<?>[] groups() default {};
Class<? extends Payload>[] 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<? extends Payload>[] 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;
@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 getNestedStoreId() { return nestedStoreId; }
public void setNestedStoreId(Short nestedStoreId) { this.nestedStoreId = nestedStoreId; }
public Short getPriorityStoreId() { return priorityStoreId; }
public void setPriorityStoreId(Short priorityStoreId) { this.priorityStoreId = priorityStoreId; }
}
static class ComposedAnnotationsDto {
@ValidRange(min = 5, max = 50)
private Short rangeField;
@FourOrMore(max = 10)
private Short partiallyOverriddenComposedField;
@ComposedFourOrMoreWithFixedMax
private Short composedFourOrMoreWithFixedMax;
@RangeWithOverridesAttributeList(5)
private Short rangeWithOverridesAttributeList;
@RangeWithRepeatedOverridesAttribute(5)
private Short rangeWithRepeatedOverridesAttribute;
@LimitedText(max = 20)
private String limitedText;
public Short getRangeField() { return rangeField; }
public void setRangeField(Short rangeField) { this.rangeField = rangeField; }
public Short getPartiallyOverriddenComposedField() { return partiallyOverriddenComposedField; }
public void setPartiallyOverriddenComposedField(Short partiallyOverriddenComposedField) {
this.partiallyOverriddenComposedField = partiallyOverriddenComposedField;
}
public Short getComposedFourOrMoreWithFixedMax() {
return composedFourOrMoreWithFixedMax;
}
public void setComposedFourOrMoreWithFixedMax(Short composedFourOrMoreWithFixedMax) {
this.composedFourOrMoreWithFixedMax = composedFourOrMoreWithFixedMax;
}
public Short getRangeWithOverridesAttributeList() {
return rangeWithOverridesAttributeList;
}
public void setRangeWithOverridesAttributeList(Short rangeWithOverridesAttributeList) {
this.rangeWithOverridesAttributeList = rangeWithOverridesAttributeList;
}
public Short getRangeWithRepeatedOverridesAttribute() {
return rangeWithRepeatedOverridesAttribute;
}
public void setRangeWithRepeatedOverridesAttribute(Short rangeWithRepeatedOverridesAttribute) {
this.rangeWithRepeatedOverridesAttribute = rangeWithRepeatedOverridesAttribute;
}
public String getLimitedText() {
return limitedText;
}
public void setLimitedText(String limitedText) {
this.limitedText = limitedText;
}
}
@Test
public void readsComposedMinMaxConstraintOnDtoField() {
Map<String, Schema> 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<String, Schema> 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<String, Schema> 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<String, Schema> 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<String, Schema> 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<String, Schema> 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: handling {@link OverridesAttribute} is not yet supported,
* and instead the annotation and its composing/meta annotations are ignored entirely.
*/
@Test
public void rangeStyleConstraintDoesNotDefineConstraintsSinceItReliesOnOverriddenValues() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
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, But we then drop them since we see that they are modified with an OverridesAttribute.
assertNull(range.getMinimum(),
"expected null since we drop the @Min from the overridden composed @ValidRange annotation");
assertNull(range.getMaximum(),
"expected null since we drop the @Max from the overridden composed @ValidRange annotation");
}
@Test
public void composedStyleConstraintUsesOnlyNonOverrideableValues() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
Schema range = (Schema) model.getProperties().get("partiallyOverriddenComposedField");
assertNotNull(range, "partiallyOverriddenComposedField property should exist");
assertEquals(range.getMinimum().longValue(), 4L,
"expected 4 from type definition since it does not have an OverridesAttribute");
assertNull(range.getMaximum(),
"expected null since we drop the @Max from the overridden composed @FourOrMore annotation");
}
@Test
public void nestedComposedStyleConstraintUsesOnlyNonOverrideableValues() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
Schema range = (Schema) model.getProperties().get("composedFourOrMoreWithFixedMax");
assertNotNull(range, "composedFourOrMoreWithFixedMax property should exist");
assertEquals(range.getMinimum().longValue(), 4L,
"expected 4 from type definition since it does not have an OverridesAttribute");
assertEquals(range.getMaximum().longValue(), 10L,
"expected 10 from type definition since we have an explicit non-overridden @Max annotation next to @FourOrMore");
}
@Test
public void detectsOverridesAttributeListContainer() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
Schema range = (Schema) model.getProperties().get("rangeWithOverridesAttributeList");
assertNotNull(range, "rangeWithOverridesAttributeList property should exist");
assertNull(range.getMinimum(),
"expected null since container-declared @OverridesAttribute makes @Min override-dependent");
assertNull(range.getMaximum(),
"expected null since container-declared @OverridesAttribute makes @Max override-dependent");
}
@Test
public void detectsRepeatedOverridesAttributeDeclarations() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
Schema range = (Schema) model.getProperties().get("rangeWithRepeatedOverridesAttribute");
assertNotNull(range, "rangeWithRepeatedOverridesAttribute property should exist");
assertNull(range.getMinimum(),
"expected null since repeated @OverridesAttribute makes @Min override-dependent");
assertNull(range.getMaximum(),
"expected null since repeated @OverridesAttribute makes @Max override-dependent");
}
/**
* Future override resolution should keep the fixed {@code min=2} and apply the use-site
* {@code max=20}. The current conservative behavior omits the whole {@code @Size}
* composing annotation once any of its attributes are override-dependent.
*/
@Test
public void partialMultiAttributeOverrideSuppressesWholeComposingAnnotation() {
Map<String, Schema> schemas = ModelConverters.getInstance().readAll(ComposedAnnotationsDto.class);
Schema model = schemas.get("ComposedAnnotationsDto");
Schema limitedText = (Schema) model.getProperties().get("limitedText");
assertNotNull(limitedText, "limitedText property should exist");
assertNull(limitedText.getMinLength(),
"expected null because the whole @Size composing annotation is currently suppressed");
assertNull(limitedText.getMaxLength(),
"expected null because swagger-core does not yet resolve use-site @OverridesAttribute values");
}
}