Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,6 @@ private void addComponentsSchemaRef(Components components, Set<String> reference
}

protected OpenAPI removeBrokenReferenceDefinitions(OpenAPI openApi) {

if (openApi == null || openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) {
return openApi;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1780,6 +1780,7 @@ protected boolean applyBeanValidatorAnnotations(Schema property, Annotation[] an
return modified;
}
}
annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations);
Map<String, Annotation> annos = new HashMap<>();
if (annotations != null) {
for (Annotation anno : annotations) {
Expand Down Expand Up @@ -1950,6 +1951,7 @@ protected boolean checkGroupValidation(Class[] groups, Set<Class> invocationGrou
}

protected boolean applyBeanValidatorAnnotationsNoGroups(Schema property, Annotation[] annotations, Schema parent, boolean applyNotNullAnnotations) {
annotations = ValidationAnnotationsUtils.expandValidationMetaAnnotations(annotations);
Map<String, Annotation> annos = new HashMap<>();
boolean modified = false;
if (annotations != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand Down Expand Up @@ -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<String, Annotation> merged = new LinkedHashMap<>();
for (Annotation a : annotations) {
if (a != null) {
merged.put(a.annotationType().getName(), a);
}
}
try {
Set<Class<?>> visited = new HashSet<>();
Queue<Annotation> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<? 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 @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<? 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;

@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<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: 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<String, Schema> 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");
}
}
Loading