diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java index 54609b445..aac7ba83b 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java @@ -663,7 +663,11 @@ public Parameter buildParam(ParameterInfo parameterInfo, Components components, * @param isParameterObject the is parameter object * @param openapiVersion the openapi version */ - public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, final Parameter parameter, final List annotations, final boolean isParameterObject, String openapiVersion) { + public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, + final Parameter parameter, + final List annotations, + final boolean isParameterObject, + String openapiVersion) { boolean annotatedNotNull = annotations != null && SchemaUtils.annotatedNotNull(annotations); if (annotatedNotNull && !isParameterObject) { parameter.setRequired(true); diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java index 5dcf686c6..1304aef40 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java @@ -6,20 +6,15 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.math.BigDecimal; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.OptionalDouble; -import java.util.OptionalInt; -import java.util.OptionalLong; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; import io.swagger.v3.oas.models.media.Schema; +import jakarta.validation.Constraint; +import jakarta.validation.OverridesAttribute; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; @@ -30,6 +25,7 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.Range; import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion; import org.springframework.lang.Nullable; @@ -229,7 +225,7 @@ public boolean fieldRequired(Field field, io.swagger.v3.oas.annotations.media.Sc * @param openapiVersion the openapi version */ public static void applyValidationsToSchema(Schema schema, List annotations, String openapiVersion) { - annotations.forEach(anno -> { + removeComposingConstraints(annotations).forEach(anno -> { String annotationName = anno.annotationType().getSimpleName(); if (annotationName.equals(Positive.class.getSimpleName())) { if (OpenApiVersion.OPENAPI_3_1.getVersion().equals(openapiVersion)) { @@ -295,6 +291,10 @@ else if (OPENAPI_STRING_TYPE.equals(type)) { if (annotationName.equals(Pattern.class.getSimpleName())) { schema.setPattern(((Pattern) anno).regexp()); } + if (annotationName.equals(Range.class.getSimpleName())) { + schema.setMinimum(BigDecimal.valueOf(((Range) anno).min())); + schema.setMaximum(BigDecimal.valueOf(((Range) anno).max())); + } }); if (schema!=null && annotatedNotNull(annotations)) { String specVersion = schema.getSpecVersion().name(); @@ -304,6 +304,50 @@ else if (OPENAPI_STRING_TYPE.equals(type)) { } } + /** + * Remove the composing constraints from the annotations. This is necessary since otherwise the annotations may + * default to the composing constraints' default value (dependent on the annotation ordering). + * An example is {@link Range} being a composed constraint for {@link Min} and {@link Max}. + * So {@link Min} and {@link Max} are removed to ensure that the constraint values are read from {@link Range}. + * + * @param constraintAnnotations constraint annotations + * @return the annotations where known composing constraints have been removed + */ + private static List removeComposingConstraints(List constraintAnnotations) { + Set> composingTypes = new HashSet<>(); + for (Annotation ann : constraintAnnotations) { + Class type = ann.annotationType(); + List> annotationOverrides = findOverrides(ann); + for (Annotation meta : type.getAnnotations()) { + if (annotationOverrides.contains(meta.annotationType())) { + composingTypes.add(meta.annotationType()); + } + } + } + return constraintAnnotations.stream().filter(annotation -> !composingTypes.contains(annotation.annotationType())).toList(); + } + + /** + * + * @param annotation the composed constraint annotation + * @return the composing annotations that are overridden with {@link OverridesAttribute} + */ + private static List> findOverrides(Annotation annotation) { + List> OverriddenConstraintAnnotations = new ArrayList<>(); + + Class type = annotation.annotationType(); + + for (Method method : type.getDeclaredMethods()) { + OverridesAttribute oa = method.getAnnotation(OverridesAttribute.class); + + if (oa != null) { + OverriddenConstraintAnnotations.add(oa.constraint()); + } + } + + return OverriddenConstraintAnnotations; + } + /** * Nullable from annotations boolean. * diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app112/PersonController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app112/PersonController.java index 46c1df589..c386f7b6f 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app112/PersonController.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app112/PersonController.java @@ -24,15 +24,15 @@ package test.org.springdoc.api.v30.app112; +import java.lang.annotation.Retention; import java.util.ArrayList; import java.util.List; import java.util.Random; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.*; +import org.hibernate.validator.constraints.Range; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -40,6 +40,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + @RestController @Validated public class PersonController { @@ -72,4 +74,21 @@ public List findByLastName(@RequestParam(name = "lastName", required = t return hardCoded; } + + @RequestMapping(path = "/persons", method = RequestMethod.GET) + public List findPersons( + @RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes, + @RequestParam(name = "height") @Range(max = 200) int height, + @RequestParam(name = "age") @Range(min = 2) int age, + @RequestParam(name = "oneToTen") @ComposedInterfaceWithStaticDefinitions int oneToTen + ) { + return List.of(); + + } + + @Min(1) + @Max(10) + @Retention(RUNTIME) + public @interface ComposedInterfaceWithStaticDefinitions { + } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app112/PersonController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app112/PersonController.java index 0bea01a10..6b9df6537 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app112/PersonController.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app112/PersonController.java @@ -24,15 +24,15 @@ package test.org.springdoc.api.v31.app112; +import java.lang.annotation.Retention; import java.util.ArrayList; import java.util.List; import java.util.Random; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.*; +import org.hibernate.validator.constraints.Range; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -40,6 +40,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + @RestController @Validated public class PersonController { @@ -72,4 +74,21 @@ public List findByLastName(@RequestParam(name = "lastName", required = t return hardCoded; } + + @RequestMapping(path = "/persons", method = RequestMethod.GET) + public List findPersons( + @RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes, + @RequestParam(name = "height") @Range(max = 200) int height, + @RequestParam(name = "age") @Range(min = 2) int age, + @RequestParam(name = "oneToTen") @ComposedInterfaceWithStaticDefinitions int oneToTen + ) { + return List.of(); + + } + + @Min(1) + @Max(10) + @Retention(RUNTIME) + public @interface ComposedInterfaceWithStaticDefinitions { + } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app112.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app112.json index 2ab571756..7e36908bb 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app112.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app112.json @@ -64,12 +64,12 @@ "required": true }, "responses": { - "415": { - "description": "Unsupported Media Type", + "500": { + "description": "Internal Server Error", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorMessage" + "$ref": "#/components/schemas/Problem" } } } @@ -84,12 +84,12 @@ } } }, - "500": { - "description": "Internal Server Error", + "415": { + "description": "Unsupported Media Type", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/ErrorMessage" } } } @@ -107,6 +107,75 @@ } } }, + "/persons": { + "get": { + "tags": [ + "person-controller" + ], + "operationId": "findPersons", + "parameters": [ + { + "name": "setsOfShoes", + "in": "query", + "required": true, + "schema": { + "maximum": 4, + "minimum": 1, + "type": "integer", + "format": "int32" + } + }, + { + "name": "height", + "in": "query", + "required": true, + "schema": { + "maximum": 200, + "minimum": 0, + "type": "integer", + "format": "int32" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "maximum": 9223372036854775807, + "minimum": 2, + "type": "integer", + "format": "int32" + } + }, + { + "name": "oneToTen", + "in": "query", + "required": true, + "schema": { + "maximum": 10, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + } + }, "/personByLastName": { "get": { "tags": [ @@ -161,12 +230,12 @@ } ], "responses": { - "415": { - "description": "Unsupported Media Type", + "500": { + "description": "Internal Server Error", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorMessage" + "$ref": "#/components/schemas/Problem" } } } @@ -181,12 +250,12 @@ } } }, - "500": { - "description": "Internal Server Error", + "415": { + "description": "Unsupported Media Type", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/ErrorMessage" } } } @@ -210,17 +279,6 @@ }, "components": { "schemas": { - "ErrorMessage": { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "Problem": { "type": "object", "properties": { @@ -232,6 +290,17 @@ } } }, + "ErrorMessage": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "Person": { "required": [ "lastName" @@ -273,4 +342,4 @@ } } } -} +} \ No newline at end of file diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app112.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app112.json index e98203f8e..db87a7295 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app112.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app112.json @@ -107,6 +107,75 @@ } } }, + "/persons": { + "get": { + "tags": [ + "person-controller" + ], + "operationId": "findPersons", + "parameters": [ + { + "name": "setsOfShoes", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "maximum": 4, + "minimum": 1 + } + }, + { + "name": "height", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "maximum": 200, + "minimum": 0 + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "maximum": 9223372036854775807, + "minimum": 2 + } + }, + { + "name": "oneToTen", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "maximum": 10, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + } + }, "/personByLastName": { "get": { "tags": [ @@ -273,4 +342,4 @@ } } } -} +} \ No newline at end of file