Skip to content

Commit d4e3db7

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

6 files changed

Lines changed: 209 additions & 27 deletions

File tree

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: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.swagger.v3.oas.annotations.Parameter;
2121
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
2222
import io.swagger.v3.oas.models.media.Schema;
23+
import jakarta.validation.Constraint;
2324
import jakarta.validation.constraints.DecimalMax;
2425
import jakarta.validation.constraints.DecimalMin;
2526
import jakarta.validation.constraints.Max;
@@ -30,6 +31,7 @@
3031
import jakarta.validation.constraints.Positive;
3132
import jakarta.validation.constraints.PositiveOrZero;
3233
import jakarta.validation.constraints.Size;
34+
import org.hibernate.validator.constraints.Range;
3335
import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion;
3436

3537
import org.springframework.lang.Nullable;
@@ -229,7 +231,7 @@ public boolean fieldRequired(Field field, io.swagger.v3.oas.annotations.media.Sc
229231
* @param openapiVersion the openapi version
230232
*/
231233
public static void applyValidationsToSchema(Schema<?> schema, List<Annotation> annotations, String openapiVersion) {
232-
annotations.forEach(anno -> {
234+
removeComposingConstraints(filterConstraintAnnotations(annotations)).forEach(anno -> {
233235
String annotationName = anno.annotationType().getSimpleName();
234236
if (annotationName.equals(Positive.class.getSimpleName())) {
235237
if (OpenApiVersion.OPENAPI_3_1.getVersion().equals(openapiVersion)) {
@@ -295,6 +297,10 @@ else if (OPENAPI_STRING_TYPE.equals(type)) {
295297
if (annotationName.equals(Pattern.class.getSimpleName())) {
296298
schema.setPattern(((Pattern) anno).regexp());
297299
}
300+
if (annotationName.equals(Range.class.getSimpleName())) {
301+
schema.setMinimum(BigDecimal.valueOf(((Range) anno).min()));
302+
schema.setMaximum(BigDecimal.valueOf(((Range) anno).max()));
303+
}
298304
});
299305
if (schema!=null && annotatedNotNull(annotations)) {
300306
String specVersion = schema.getSpecVersion().name();
@@ -304,6 +310,40 @@ else if (OPENAPI_STRING_TYPE.equals(type)) {
304310
}
305311
}
306312

313+
/**
314+
* Get all annotations of type {@link Constraint}.
315+
*
316+
* @param annotations annotations
317+
* @return the {@link Constraint} annotations
318+
*/
319+
private static List<Annotation> filterConstraintAnnotations(List<Annotation> annotations) {
320+
return annotations.stream()
321+
.filter(annotation -> annotation.annotationType().isAnnotationPresent(Constraint.class))
322+
.toList();
323+
}
324+
325+
/**
326+
* Remove the composing constraints from the annotations. This is necessary since otherwise the annotations may
327+
* default to the composing constraints' default value (dependent on the annotation ordering).
328+
* An example is {@link Range} being a composed constraint for {@link Min} and {@link Max}.
329+
* So {@link Min} and {@link Max} are removed to ensure that the constraint values are read from {@link Range}.
330+
*
331+
* @param constraintAnnotations constraint annotations
332+
* @return the annotations where known composing constraints have been removed
333+
*/
334+
private static List<Annotation> removeComposingConstraints(List<Annotation> constraintAnnotations) {
335+
Set<Class<? extends Annotation>> composingTypes = new HashSet<>();
336+
for (Annotation ann : constraintAnnotations) {
337+
Class<? extends Annotation> type = ann.annotationType();
338+
for (Annotation meta : type.getAnnotations()) {
339+
if (meta.annotationType().isAnnotationPresent(Constraint.class)) {
340+
composingTypes.add(meta.annotationType());
341+
}
342+
}
343+
}
344+
return constraintAnnotations.stream().filter(annotation -> !composingTypes.contains(annotation.annotationType())).toList();
345+
}
346+
307347
/**
308348
* Nullable from annotations boolean.
309349
*

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import jakarta.validation.constraints.NotNull;
3434
import jakarta.validation.constraints.Size;
3535

36+
import org.hibernate.validator.constraints.Range;
3637
import org.springframework.validation.annotation.Validated;
3738
import org.springframework.web.bind.annotation.RequestBody;
3839
import org.springframework.web.bind.annotation.RequestMapping;
@@ -72,4 +73,14 @@ public List<Person> findByLastName(@RequestParam(name = "lastName", required = t
7273
return hardCoded;
7374

7475
}
76+
77+
@RequestMapping(path = "/persons", method = RequestMethod.GET)
78+
public List<Person> findPersons(
79+
@RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes,
80+
@RequestParam(name = "height") @Range(max = 200) int height,
81+
@RequestParam(name = "age") @Range(min = 2) int age
82+
) {
83+
return List.of();
84+
85+
}
7586
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import jakarta.validation.constraints.NotNull;
3434
import jakarta.validation.constraints.Size;
3535

36+
import org.hibernate.validator.constraints.Range;
3637
import org.springframework.validation.annotation.Validated;
3738
import org.springframework.web.bind.annotation.RequestBody;
3839
import org.springframework.web.bind.annotation.RequestMapping;
@@ -72,4 +73,14 @@ public List<Person> findByLastName(@RequestParam(name = "lastName", required = t
7273
return hardCoded;
7374

7475
}
76+
77+
@RequestMapping(path = "/persons", method = RequestMethod.GET)
78+
public List<Person> findPersons(
79+
@RequestParam(name = "setsOfShoes") @Range(min = 1, max = 4) int setsOfShoes,
80+
@RequestParam(name = "height") @Range(max = 200) int height,
81+
@RequestParam(name = "age") @Range(min = 2) int age
82+
) {
83+
return List.of();
84+
85+
}
7586
}

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

Lines changed: 82 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,64 @@
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+
"responses": {
152+
"200": {
153+
"description": "OK",
154+
"content": {
155+
"*/*": {
156+
"schema": {
157+
"type": "array",
158+
"items": {
159+
"$ref": "#/components/schemas/Person"
160+
}
161+
}
162+
}
163+
}
164+
}
165+
}
166+
}
167+
},
110168
"/personByLastName": {
111169
"get": {
112170
"tags": [
@@ -161,12 +219,12 @@
161219
}
162220
],
163221
"responses": {
164-
"415": {
165-
"description": "Unsupported Media Type",
222+
"500": {
223+
"description": "Internal Server Error",
166224
"content": {
167225
"*/*": {
168226
"schema": {
169-
"$ref": "#/components/schemas/ErrorMessage"
227+
"$ref": "#/components/schemas/Problem"
170228
}
171229
}
172230
}
@@ -181,12 +239,12 @@
181239
}
182240
}
183241
},
184-
"500": {
185-
"description": "Internal Server Error",
242+
"415": {
243+
"description": "Unsupported Media Type",
186244
"content": {
187245
"*/*": {
188246
"schema": {
189-
"$ref": "#/components/schemas/Problem"
247+
"$ref": "#/components/schemas/ErrorMessage"
190248
}
191249
}
192250
}
@@ -210,17 +268,6 @@
210268
},
211269
"components": {
212270
"schemas": {
213-
"ErrorMessage": {
214-
"type": "object",
215-
"properties": {
216-
"errors": {
217-
"type": "array",
218-
"items": {
219-
"type": "string"
220-
}
221-
}
222-
}
223-
},
224271
"Problem": {
225272
"type": "object",
226273
"properties": {
@@ -232,6 +279,17 @@
232279
}
233280
}
234281
},
282+
"ErrorMessage": {
283+
"type": "object",
284+
"properties": {
285+
"errors": {
286+
"type": "array",
287+
"items": {
288+
"type": "string"
289+
}
290+
}
291+
}
292+
},
235293
"Person": {
236294
"required": [
237295
"lastName"
@@ -273,4 +331,4 @@
273331
}
274332
}
275333
}
276-
}
334+
}

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,64 @@
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+
"responses": {
152+
"200": {
153+
"description": "OK",
154+
"content": {
155+
"*/*": {
156+
"schema": {
157+
"type": "array",
158+
"items": {
159+
"$ref": "#/components/schemas/Person"
160+
}
161+
}
162+
}
163+
}
164+
}
165+
}
166+
}
167+
},
110168
"/personByLastName": {
111169
"get": {
112170
"tags": [
@@ -273,4 +331,4 @@
273331
}
274332
}
275333
}
276-
}
334+
}

0 commit comments

Comments
 (0)