Skip to content

Commit b773847

Browse files
Add support for the @range constraint validation annotation
1 parent 3e7b337 commit b773847

File tree

6 files changed

+265
-41
lines changed

6 files changed

+265
-41
lines changed

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,11 @@ public Parameter buildParam(ParameterInfo parameterInfo, Components components,
663663
* @param isParameterObject the is parameter object
664664
* @param openapiVersion the openapi version
665665
*/
666-
public void applyBeanValidatorAnnotations(final MethodParameter methodParameter, final Parameter parameter, final List<Annotation> annotations, final boolean isParameterObject, String openapiVersion) {
666+
public void applyBeanValidatorAnnotations(final MethodParameter methodParameter,
667+
final Parameter parameter,
668+
final List<Annotation> annotations,
669+
final boolean isParameterObject,
670+
String openapiVersion) {
667671
boolean annotatedNotNull = annotations != null && SchemaUtils.annotatedNotNull(annotations);
668672
if (annotatedNotNull && !isParameterObject) {
669673
parameter.setRequired(true);

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/utils/SchemaUtils.java

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,15 @@
66
import java.lang.reflect.Field;
77
import java.lang.reflect.Method;
88
import java.math.BigDecimal;
9-
import java.util.Collection;
10-
import java.util.HashSet;
11-
import java.util.List;
12-
import java.util.Optional;
13-
import java.util.OptionalDouble;
14-
import java.util.OptionalInt;
15-
import java.util.OptionalLong;
16-
import java.util.Set;
9+
import java.util.*;
1710
import java.util.stream.Collectors;
1811

1912
import com.fasterxml.jackson.annotation.JsonProperty;
2013
import io.swagger.v3.oas.annotations.Parameter;
2114
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
2215
import io.swagger.v3.oas.models.media.Schema;
16+
import jakarta.validation.Constraint;
17+
import jakarta.validation.OverridesAttribute;
2318
import jakarta.validation.constraints.DecimalMax;
2419
import jakarta.validation.constraints.DecimalMin;
2520
import jakarta.validation.constraints.Max;
@@ -30,6 +25,7 @@
3025
import jakarta.validation.constraints.Positive;
3126
import jakarta.validation.constraints.PositiveOrZero;
3227
import jakarta.validation.constraints.Size;
28+
import org.hibernate.validator.constraints.Range;
3329
import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion;
3430

3531
import org.springframework.lang.Nullable;
@@ -229,7 +225,7 @@ public boolean fieldRequired(Field field, io.swagger.v3.oas.annotations.media.Sc
229225
* @param openapiVersion the openapi version
230226
*/
231227
public static void applyValidationsToSchema(Schema<?> schema, List<Annotation> annotations, String openapiVersion) {
232-
annotations.forEach(anno -> {
228+
removeComposingConstraints(annotations).forEach(anno -> {
233229
String annotationName = anno.annotationType().getSimpleName();
234230
if (annotationName.equals(Positive.class.getSimpleName())) {
235231
if (OpenApiVersion.OPENAPI_3_1.getVersion().equals(openapiVersion)) {
@@ -295,6 +291,10 @@ else if (OPENAPI_STRING_TYPE.equals(type)) {
295291
if (annotationName.equals(Pattern.class.getSimpleName())) {
296292
schema.setPattern(((Pattern) anno).regexp());
297293
}
294+
if (annotationName.equals(Range.class.getSimpleName())) {
295+
schema.setMinimum(BigDecimal.valueOf(((Range) anno).min()));
296+
schema.setMaximum(BigDecimal.valueOf(((Range) anno).max()));
297+
}
298298
});
299299
if (schema!=null && annotatedNotNull(annotations)) {
300300
String specVersion = schema.getSpecVersion().name();
@@ -304,6 +304,50 @@ else if (OPENAPI_STRING_TYPE.equals(type)) {
304304
}
305305
}
306306

307+
/**
308+
* Remove the composing constraints from the annotations. This is necessary since otherwise the annotations may
309+
* default to the composing constraints' default value (dependent on the annotation ordering).
310+
* An example is {@link Range} being a composed constraint for {@link Min} and {@link Max}.
311+
* So {@link Min} and {@link Max} are removed to ensure that the constraint values are read from {@link Range}.
312+
*
313+
* @param constraintAnnotations constraint annotations
314+
* @return the annotations where known composing constraints have been removed
315+
*/
316+
private static List<Annotation> removeComposingConstraints(List<Annotation> constraintAnnotations) {
317+
Set<Class<? extends Annotation>> composingTypes = new HashSet<>();
318+
for (Annotation ann : constraintAnnotations) {
319+
Class<? extends Annotation> type = ann.annotationType();
320+
List<Class<? extends Annotation>> annotationOverrides = findOverrides(ann);
321+
for (Annotation meta : type.getAnnotations()) {
322+
if (annotationOverrides.contains(meta.annotationType())) {
323+
composingTypes.add(meta.annotationType());
324+
}
325+
}
326+
}
327+
return constraintAnnotations.stream().filter(annotation -> !composingTypes.contains(annotation.annotationType())).toList();
328+
}
329+
330+
/**
331+
*
332+
* @param annotation the composed constraint annotation
333+
* @return the composing annotations that are overridden with {@link OverridesAttribute}
334+
*/
335+
private static List<Class<? extends Annotation>> findOverrides(Annotation annotation) {
336+
List<Class<? extends Annotation>> OverriddenConstraintAnnotations = new ArrayList<>();
337+
338+
Class<? extends Annotation> type = annotation.annotationType();
339+
340+
for (Method method : type.getDeclaredMethods()) {
341+
OverridesAttribute oa = method.getAnnotation(OverridesAttribute.class);
342+
343+
if (oa != null) {
344+
OverriddenConstraintAnnotations.add(oa.constraint());
345+
}
346+
}
347+
348+
return OverriddenConstraintAnnotations;
349+
}
350+
307351
/**
308352
* Nullable from annotations boolean.
309353
*

springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app112/PersonController.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,24 @@
2424

2525
package test.org.springdoc.api.v30.app112;
2626

27+
import java.lang.annotation.Retention;
2728
import java.util.ArrayList;
2829
import java.util.List;
2930
import java.util.Random;
3031

3132
import jakarta.validation.Valid;
32-
import jakarta.validation.constraints.NotBlank;
33-
import jakarta.validation.constraints.NotNull;
34-
import jakarta.validation.constraints.Size;
33+
import jakarta.validation.constraints.*;
3534

35+
import org.hibernate.validator.constraints.Range;
3636
import org.springframework.validation.annotation.Validated;
3737
import org.springframework.web.bind.annotation.RequestBody;
3838
import org.springframework.web.bind.annotation.RequestMapping;
3939
import org.springframework.web.bind.annotation.RequestMethod;
4040
import org.springframework.web.bind.annotation.RequestParam;
4141
import org.springframework.web.bind.annotation.RestController;
4242

43+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
44+
4345
@RestController
4446
@Validated
4547
public class PersonController {
@@ -72,4 +74,21 @@ public List<Person> findByLastName(@RequestParam(name = "lastName", required = t
7274
return hardCoded;
7375

7476
}
77+
78+
@RequestMapping(path = "/persons", method = RequestMethod.GET)
79+
public List<Person> findPersons(
80+
@RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes,
81+
@RequestParam(name = "height") @Range(max = 200) int height,
82+
@RequestParam(name = "age") @Range(min = 2) int age,
83+
@RequestParam(name = "oneToTen") @ComposedInterfaceWithStaticDefinitions int oneToTen
84+
) {
85+
return List.of();
86+
87+
}
88+
89+
@Min(0)
90+
@Max(10)
91+
@Retention(RUNTIME)
92+
public @interface ComposedInterfaceWithStaticDefinitions {
93+
}
7594
}

springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app112/PersonController.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,24 @@
2424

2525
package test.org.springdoc.api.v31.app112;
2626

27+
import java.lang.annotation.Retention;
2728
import java.util.ArrayList;
2829
import java.util.List;
2930
import java.util.Random;
3031

3132
import jakarta.validation.Valid;
32-
import jakarta.validation.constraints.NotBlank;
33-
import jakarta.validation.constraints.NotNull;
34-
import jakarta.validation.constraints.Size;
33+
import jakarta.validation.constraints.*;
3534

35+
import org.hibernate.validator.constraints.Range;
3636
import org.springframework.validation.annotation.Validated;
3737
import org.springframework.web.bind.annotation.RequestBody;
3838
import org.springframework.web.bind.annotation.RequestMapping;
3939
import org.springframework.web.bind.annotation.RequestMethod;
4040
import org.springframework.web.bind.annotation.RequestParam;
4141
import org.springframework.web.bind.annotation.RestController;
4242

43+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
44+
4345
@RestController
4446
@Validated
4547
public class PersonController {
@@ -72,4 +74,21 @@ public List<Person> findByLastName(@RequestParam(name = "lastName", required = t
7274
return hardCoded;
7375

7476
}
77+
78+
@RequestMapping(path = "/persons", method = RequestMethod.GET)
79+
public List<Person> findPersons(
80+
@RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes,
81+
@RequestParam(name = "height") @Range(max = 200) int height,
82+
@RequestParam(name = "age") @Range(min = 2) int age,
83+
@RequestParam(name = "oneToTen") @ComposedInterfaceWithStaticDefinitions int oneToTen
84+
) {
85+
return List.of();
86+
87+
}
88+
89+
@Min(0)
90+
@Max(10)
91+
@Retention(RUNTIME)
92+
public @interface ComposedInterfaceWithStaticDefinitions {
93+
}
7594
}

springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app112.json

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@
6464
"required": true
6565
},
6666
"responses": {
67-
"415": {
68-
"description": "Unsupported Media Type",
67+
"500": {
68+
"description": "Internal Server Error",
6969
"content": {
7070
"*/*": {
7171
"schema": {
72-
"$ref": "#/components/schemas/ErrorMessage"
72+
"$ref": "#/components/schemas/Problem"
7373
}
7474
}
7575
}
@@ -84,12 +84,12 @@
8484
}
8585
}
8686
},
87-
"500": {
88-
"description": "Internal Server Error",
87+
"415": {
88+
"description": "Unsupported Media Type",
8989
"content": {
9090
"*/*": {
9191
"schema": {
92-
"$ref": "#/components/schemas/Problem"
92+
"$ref": "#/components/schemas/ErrorMessage"
9393
}
9494
}
9595
}
@@ -107,6 +107,75 @@
107107
}
108108
}
109109
},
110+
"/persons": {
111+
"get": {
112+
"tags": [
113+
"person-controller"
114+
],
115+
"operationId": "findPersons",
116+
"parameters": [
117+
{
118+
"name": "setsOfShoes",
119+
"in": "query",
120+
"required": true,
121+
"schema": {
122+
"maximum": 4,
123+
"minimum": 1,
124+
"type": "integer",
125+
"format": "int32"
126+
}
127+
},
128+
{
129+
"name": "height",
130+
"in": "query",
131+
"required": true,
132+
"schema": {
133+
"maximum": 200,
134+
"minimum": 0,
135+
"type": "integer",
136+
"format": "int32"
137+
}
138+
},
139+
{
140+
"name": "age",
141+
"in": "query",
142+
"required": true,
143+
"schema": {
144+
"maximum": 9223372036854775807,
145+
"minimum": 2,
146+
"type": "integer",
147+
"format": "int32"
148+
}
149+
},
150+
{
151+
"name": "oneToTen",
152+
"in": "query",
153+
"required": true,
154+
"schema": {
155+
"maximum": 10,
156+
"minimum": 0,
157+
"type": "integer",
158+
"format": "int32"
159+
}
160+
}
161+
],
162+
"responses": {
163+
"200": {
164+
"description": "OK",
165+
"content": {
166+
"*/*": {
167+
"schema": {
168+
"type": "array",
169+
"items": {
170+
"$ref": "#/components/schemas/Person"
171+
}
172+
}
173+
}
174+
}
175+
}
176+
}
177+
}
178+
},
110179
"/personByLastName": {
111180
"get": {
112181
"tags": [
@@ -161,12 +230,12 @@
161230
}
162231
],
163232
"responses": {
164-
"415": {
165-
"description": "Unsupported Media Type",
233+
"500": {
234+
"description": "Internal Server Error",
166235
"content": {
167236
"*/*": {
168237
"schema": {
169-
"$ref": "#/components/schemas/ErrorMessage"
238+
"$ref": "#/components/schemas/Problem"
170239
}
171240
}
172241
}
@@ -181,12 +250,12 @@
181250
}
182251
}
183252
},
184-
"500": {
185-
"description": "Internal Server Error",
253+
"415": {
254+
"description": "Unsupported Media Type",
186255
"content": {
187256
"*/*": {
188257
"schema": {
189-
"$ref": "#/components/schemas/Problem"
258+
"$ref": "#/components/schemas/ErrorMessage"
190259
}
191260
}
192261
}
@@ -210,17 +279,6 @@
210279
},
211280
"components": {
212281
"schemas": {
213-
"ErrorMessage": {
214-
"type": "object",
215-
"properties": {
216-
"errors": {
217-
"type": "array",
218-
"items": {
219-
"type": "string"
220-
}
221-
}
222-
}
223-
},
224282
"Problem": {
225283
"type": "object",
226284
"properties": {
@@ -232,6 +290,17 @@
232290
}
233291
}
234292
},
293+
"ErrorMessage": {
294+
"type": "object",
295+
"properties": {
296+
"errors": {
297+
"type": "array",
298+
"items": {
299+
"type": "string"
300+
}
301+
}
302+
}
303+
},
235304
"Person": {
236305
"required": [
237306
"lastName"
@@ -273,4 +342,4 @@
273342
}
274343
}
275344
}
276-
}
345+
}

0 commit comments

Comments
 (0)