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; } 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); + } +} +