From d4e3db73be736780b06eaf650b43eb48bdaaf76f Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:11:42 +0100 Subject: [PATCH] Add support for the @Range constraint validation annotation --- .../core/service/AbstractRequestService.java | 6 +- .../org/springdoc/core/utils/SchemaUtils.java | 42 ++++++- .../api/v30/app112/PersonController.java | 11 ++ .../api/v31/app112/PersonController.java | 11 ++ .../test/resources/results/3.0.1/app112.json | 106 ++++++++++++++---- .../test/resources/results/3.1.0/app112.json | 60 +++++++++- 6 files changed, 209 insertions(+), 27 deletions(-) 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..d413b4cc3 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 @@ -20,6 +20,7 @@ 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.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; @@ -30,6 +31,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 +231,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(filterConstraintAnnotations(annotations)).forEach(anno -> { String annotationName = anno.annotationType().getSimpleName(); if (annotationName.equals(Positive.class.getSimpleName())) { if (OpenApiVersion.OPENAPI_3_1.getVersion().equals(openapiVersion)) { @@ -295,6 +297,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 +310,40 @@ else if (OPENAPI_STRING_TYPE.equals(type)) { } } + /** + * Get all annotations of type {@link Constraint}. + * + * @param annotations annotations + * @return the {@link Constraint} annotations + */ + private static List filterConstraintAnnotations(List annotations) { + return annotations.stream() + .filter(annotation -> annotation.annotationType().isAnnotationPresent(Constraint.class)) + .toList(); + } + + /** + * 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(); + for (Annotation meta : type.getAnnotations()) { + if (meta.annotationType().isAnnotationPresent(Constraint.class)) { + composingTypes.add(meta.annotationType()); + } + } + } + return constraintAnnotations.stream().filter(annotation -> !composingTypes.contains(annotation.annotationType())).toList(); + } + /** * 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..90fae46c8 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 @@ -33,6 +33,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +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; @@ -72,4 +73,14 @@ 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 + ) { + return List.of(); + + } } 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..00c8a55aa 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 @@ -33,6 +33,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +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; @@ -72,4 +73,14 @@ 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 + ) { + return List.of(); + + } } 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..4a7442f85 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,64 @@ } } }, + "/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" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + } + }, "/personByLastName": { "get": { "tags": [ @@ -161,12 +219,12 @@ } ], "responses": { - "415": { - "description": "Unsupported Media Type", + "500": { + "description": "Internal Server Error", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorMessage" + "$ref": "#/components/schemas/Problem" } } } @@ -181,12 +239,12 @@ } } }, - "500": { - "description": "Internal Server Error", + "415": { + "description": "Unsupported Media Type", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/Problem" + "$ref": "#/components/schemas/ErrorMessage" } } } @@ -210,17 +268,6 @@ }, "components": { "schemas": { - "ErrorMessage": { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "Problem": { "type": "object", "properties": { @@ -232,6 +279,17 @@ } } }, + "ErrorMessage": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "Person": { "required": [ "lastName" @@ -273,4 +331,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..ee6e65fa7 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,64 @@ } } }, + "/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" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + } + }, "/personByLastName": { "get": { "tags": [ @@ -273,4 +331,4 @@ } } } -} +} \ No newline at end of file