From 996a8ba7ddbaaa0bb40b943213fcad4af7ac745e Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:15:03 +0200 Subject: [PATCH] feat: resolve annotations that are within Optional parameters --- .../core/service/GenericParameterService.java | 27 +++- .../api/v30/app270/HelloController.java | 61 +++++++++ .../api/v30/app270/SpringDocApp270Test.java | 39 ++++++ .../api/v31/app270/HelloController.java | 61 +++++++++ .../api/v31/app270/SpringDocApp270Test.java | 39 ++++++ .../test/resources/results/3.0.1/app270.json | 118 ++++++++++++++++++ .../test/resources/results/3.1.0/app270.json | 103 +++++++++++++++ 7 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java index 7caf020bb..4490d95b7 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java @@ -431,7 +431,7 @@ Schema calculateSchema(Components components, ParameterInfo parameterInfo, Reque */ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodParameter methodParameter) { if (methodParameter instanceof DelegatingMethodParameter delegatingMethodParameter - && delegatingMethodParameter.getField() != null) { + && delegatingMethodParameter.getField() != null) { AnnotatedType annotated = delegatingMethodParameter.getField().getAnnotatedType(); Type type = GenericTypeResolver.resolveType(annotated.getType(), methodParameter.getContainingClass()); return new TypeAndTypeAnnotations(type, Arrays.asList(annotationsFromAnnotatedTypeArguments(annotated))); @@ -448,7 +448,30 @@ private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodP : new TypeAndTypeAnnotations(type, new ArrayList<>()); } - return new TypeAndTypeAnnotations(type, Arrays.asList(methodParameter.getParameterType().getAnnotations())); + List typeAnnotations = new ArrayList<>(Arrays.asList(methodParameter.getParameterType().getAnnotations())); + if (Optional.class.isAssignableFrom(methodParameter.getParameterType())) { + typeAnnotations.addAll(Arrays.asList(annotationsFromAnnotatedTypeArguments(getParameterAnnotatedType(methodParameter)))); + } + return new TypeAndTypeAnnotations(type, typeAnnotations); + } + + /** + * Resolves the {@link AnnotatedType} of a method parameter so that annotations declared on its + * generic type arguments (for example inside an {@link Optional}) can be inspected. + * + * @param methodParameter the method parameter + * @return the annotated type, or {@code null} if it cannot be resolved + */ + private static AnnotatedType getParameterAnnotatedType(MethodParameter methodParameter) { + int index = methodParameter.getParameterIndex(); + if (index < 0) { + return null; + } + java.lang.reflect.Parameter[] parameters = methodParameter.getExecutable().getParameters(); + if (index >= parameters.length) { + return null; + } + return parameters[index].getAnnotatedType(); } /** diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java new file mode 100644 index 000000000..bfacd1f1f --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/HelloController.java @@ -0,0 +1,61 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app270; + +import java.util.List; +import java.util.Optional; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Verifies that Bean Validation constraint annotations declared on the type argument of an + * {@link Optional} controller parameter are read and applied to the generated OpenAPI schema. + */ +@RestController +public class HelloController { + + @GetMapping("/api/string") + String testString(@RequestParam Optional<@Size(min = 2, max = 10) @Pattern(regexp = "[a-z]+") String> name) { + return null; + } + + @GetMapping("/api/integer") + String testInteger(@RequestParam Optional<@Min(5) Integer> age) { + return null; + } + + @GetMapping("/api/list") + String testList(@RequestParam @Size(min = 2, max = 10) List<@Pattern(regexp = "[a-z]+") String> age) { + return null; + } + +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java new file mode 100644 index 000000000..c217a18a9 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app270/SpringDocApp270Test.java @@ -0,0 +1,39 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v30.app270; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +public class SpringDocApp270Test extends AbstractSpringDocV30Test { + + + @SpringBootApplication + static class SpringDocTestApp { + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java new file mode 100644 index 000000000..e32057b52 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/HelloController.java @@ -0,0 +1,61 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app270; + +import java.util.List; +import java.util.Optional; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Verifies that Bean Validation constraint annotations declared on the type argument of an + * {@link Optional} controller parameter are read and applied to the generated OpenAPI schema. + */ +@RestController +public class HelloController { + + @GetMapping("/api/string") + String testString(@RequestParam Optional<@Size(min = 2, max = 10) @Pattern(regexp = "[a-z]+") String> name) { + return null; + } + + @GetMapping("/api/integer") + String testInteger(@RequestParam Optional<@Min(5) Integer> age) { + return null; + } + + @GetMapping("/api/list") + String testList(@RequestParam @Size(min = 2, max = 10) List<@Pattern(regexp = "[a-z]+") String> age) { + return null; + } + +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java new file mode 100644 index 000000000..9c6b68eae --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app270/SpringDocApp270Test.java @@ -0,0 +1,39 @@ +/* + * + * * + * * * + * * * * + * * * * * Copyright 2019-2026 the original author or authors. + * * * * * + * * * * * Licensed under the Apache License, Version 2.0 (the "License"); + * * * * * you may not use this file except in compliance with the License. + * * * * * You may obtain a copy of the License at + * * * * * + * * * * * https://www.apache.org/licenses/LICENSE-2.0 + * * * * * + * * * * * Unless required by applicable law or agreed to in writing, software + * * * * * distributed under the License is distributed on an "AS IS" BASIS, + * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * * * * See the License for the specific language governing permissions and + * * * * * limitations under the License. + * * * * + * * * + * * + * + */ + +package test.org.springdoc.api.v31.app270; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +public class SpringDocApp270Test extends AbstractSpringDocTest { + + + @SpringBootApplication + static class SpringDocTestApp { + + } +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json new file mode 100644 index 000000000..1e6765c92 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app270.json @@ -0,0 +1,118 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/api/string": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testString", + "parameters": [ + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "maxLength": 10, + "minLength": 2, + "pattern": "[a-z]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/list": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testList", + "parameters": [ + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "maxItems": 10, + "minItems": 2, + "type": "array", + "items": { + "pattern": "[a-z]+", + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/integer": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "testInteger", + "parameters": [ + { + "name": "age", + "in": "query", + "required": false, + "schema": { + "minimum": 5, + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} + diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json new file mode 100644 index 000000000..744a8d655 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app270.json @@ -0,0 +1,103 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/api/string" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testString", + "parameters" : [ { + "name" : "name", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string", + "maxLength" : 10, + "minLength" : 2, + "pattern" : "[a-z]+" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/api/list" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testList", + "parameters" : [ { + "name" : "age", + "in" : "query", + "required" : true, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "[a-z]+" + }, + "maxItems" : 10, + "minItems" : 2 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, + "/api/integer" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "testInteger", + "parameters" : [ { + "name" : "age", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "minimum" : 5 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +} \ No newline at end of file