From ee7ab4dd8c1edf31be0139d9e5ac3b3e983ef1f4 Mon Sep 17 00:00:00 2001 From: Matt McClellan Date: Fri, 10 Apr 2026 08:42:30 -0500 Subject: [PATCH] Fix an issue with annotated types with generics on parameters --- .../core/service/GenericParameterService.java | 70 ++++++++++++++---- .../api/v30/app249/HelloController.java | 31 ++++++++ .../api/v30/app249/PersonQueryFilter.java | 37 ++++++++++ .../api/v30/app249/SpringDocApp249Test.java | 32 +++++++++ .../api/v31/app249/HelloController.java | 31 ++++++++ .../api/v31/app249/PersonQueryFilter.java | 37 ++++++++++ .../api/v31/app249/SpringDocApp249Test.java | 32 +++++++++ .../test/resources/results/3.0.1/app249.json | 72 +++++++++++++++++++ .../test/resources/results/3.1.0/app249.json | 64 +++++++++++++++++ 9 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/PersonQueryFilter.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/SpringDocApp249Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/HelloController.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/PersonQueryFilter.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/SpringDocApp249Test.java create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app249.json create mode 100644 springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app249.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 5032ecd25..d7d630c06 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 @@ -28,6 +28,8 @@ import java.io.IOException; import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.AnnotatedType; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; @@ -385,22 +387,10 @@ Schema calculateSchema(Components components, ParameterInfo parameterInfo, Reque MethodParameter methodParameter = parameterInfo.getMethodParameter(); if (parameterInfo.getParameterModel() == null || parameterInfo.getParameterModel().getSchema() == null) { - Type type = GenericTypeResolver.resolveType(methodParameter.getGenericParameterType(), methodParameter.getContainingClass()); Annotation[] paramAnnotations = getParameterAnnotations(methodParameter); - Annotation[] typeAnnotations = new Annotation[0]; - if (KotlinDetector.isKotlinPresent() - && KotlinDetector.isKotlinReflectPresent() - && KotlinDetector.isKotlinType(methodParameter.getContainingClass()) - && type == String.class) { - Class restored = KotlinInlineParameterResolver - .resolveInlineType(methodParameter, type); - if (restored != null) { - type = restored; - typeAnnotations = ((Class) type).getAnnotations(); - } - } else { - typeAnnotations = methodParameter.getParameterType().getAnnotations(); - } + TypeAndTypeAnnotations resolved = resolveTypeAndTypeAnnotationsForParameter(methodParameter); + Type type = resolved.type(); + Annotation[] typeAnnotations = resolved.typeAnnotations(); Annotation[] mergedAnnotations = Stream.concat( Arrays.stream(paramAnnotations), @@ -437,6 +427,56 @@ Schema calculateSchema(Components components, ParameterInfo parameterInfo, Reque return schemaN; } + /** + * Resolves type and type annotations for schema extraction (flattened {@code @ParameterObject} field, + * Kotlin inline {@code String}, or default). + * + * @param methodParameter the method parameter + * @return the resolved type and type annotations + */ + private TypeAndTypeAnnotations resolveTypeAndTypeAnnotationsForParameter(MethodParameter methodParameter) { + if (methodParameter instanceof DelegatingMethodParameter delegatingMethodParameter + && delegatingMethodParameter.getField() != null) { + AnnotatedType annotated = delegatingMethodParameter.getField().getAnnotatedType(); + Type type = GenericTypeResolver.resolveType(annotated.getType(), methodParameter.getContainingClass()); + return new TypeAndTypeAnnotations(type, annotationsFromAnnotatedTypeArguments(annotated)); + } + + Type type = GenericTypeResolver.resolveType(methodParameter.getGenericParameterType(), methodParameter.getContainingClass()); + if (KotlinDetector.isKotlinPresent() + && KotlinDetector.isKotlinReflectPresent() + && KotlinDetector.isKotlinType(methodParameter.getContainingClass()) + && type == String.class) { + Class restored = KotlinInlineParameterResolver.resolveInlineType(methodParameter, type); + return restored != null + ? new TypeAndTypeAnnotations(restored, restored.getAnnotations()) + : new TypeAndTypeAnnotations(type, new Annotation[0]); + } + + return new TypeAndTypeAnnotations(type, methodParameter.getParameterType().getAnnotations()); + } + + /** + * Pair of resolved Java type and type annotations merged with parameter annotations for {@code extractSchema}. + */ + private record TypeAndTypeAnnotations(Type type, Annotation[] typeAnnotations) { + } + + /** + * Collects annotations declared on each type argument of an {@link AnnotatedParameterizedType}. + * + * @param annotatedType the annotated type + * @return a new array, possibly empty + */ + private static Annotation[] annotationsFromAnnotatedTypeArguments(AnnotatedType annotatedType) { + if (!(annotatedType instanceof AnnotatedParameterizedType apt)) { + return new Annotation[0]; + } + return Arrays.stream(apt.getAnnotatedActualTypeArguments()) + .flatMap(typeArg -> Arrays.stream(typeArg.getAnnotations())) + .toArray(Annotation[]::new); + } + /** * Calculate request body schema schema. * diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/HelloController.java new file mode 100644 index 000000000..f085f3499 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/HelloController.java @@ -0,0 +1,31 @@ +/* + * 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.app249; + +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping("/items") + public String list(@ParameterObject PersonQueryFilter criteria) { + return "ok"; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/PersonQueryFilter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/PersonQueryFilter.java new file mode 100644 index 000000000..569174938 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/PersonQueryFilter.java @@ -0,0 +1,37 @@ +/* + * 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.app249; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; + +/** + * Sample person search criteria: several {@code List} query parameters; only one uses a type-use + * {@link Pattern} on the element type. + */ +public record PersonQueryFilter( + List firstNames, + List middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + + public PersonQueryFilter { + firstNames = firstNames != null ? firstNames : List.of(); + middleNames = middleNames != null ? middleNames : List.of(); + phoneNumbers = phoneNumbers != null ? phoneNumbers : List.of(); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/SpringDocApp249Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/SpringDocApp249Test.java new file mode 100644 index 000000000..703065263 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app249/SpringDocApp249Test.java @@ -0,0 +1,32 @@ +/* + * 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.app249; + +import test.org.springdoc.api.v30.AbstractSpringDocV30Test; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: {@code @Pattern} on one {@code List} field in a {@code @ParameterObject} must not + * be applied to sibling {@code List} query parameters' item schemas. + */ +public class SpringDocApp249Test extends AbstractSpringDocV30Test { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/HelloController.java new file mode 100644 index 000000000..491d369c8 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/HelloController.java @@ -0,0 +1,31 @@ +/* + * 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.app249; + +import org.springdoc.core.annotations.ParameterObject; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + + @GetMapping("/items") + public String list(@ParameterObject PersonQueryFilter criteria) { + return "ok"; + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/PersonQueryFilter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/PersonQueryFilter.java new file mode 100644 index 000000000..f2b30c2d3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/PersonQueryFilter.java @@ -0,0 +1,37 @@ +/* + * 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.app249; + +import java.util.List; + +import jakarta.validation.constraints.Pattern; + +/** + * Sample person search criteria: several {@code List} query parameters; only one uses a type-use + * {@link Pattern} on the element type. + */ +public record PersonQueryFilter( + List firstNames, + List middleNames, + List<@Pattern(regexp = "^\\d+$") String> phoneNumbers) { + + public PersonQueryFilter { + firstNames = firstNames != null ? firstNames : List.of(); + middleNames = middleNames != null ? middleNames : List.of(); + phoneNumbers = phoneNumbers != null ? phoneNumbers : List.of(); + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/SpringDocApp249Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/SpringDocApp249Test.java new file mode 100644 index 000000000..39ff6ffa3 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app249/SpringDocApp249Test.java @@ -0,0 +1,32 @@ +/* + * 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.app249; + +import test.org.springdoc.api.v31.AbstractSpringDocTest; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Regression: {@code @Pattern} on one {@code List} field in a {@code @ParameterObject} must not + * be applied to sibling {@code List} query parameters' item schemas. + */ +public class SpringDocApp249Test extends AbstractSpringDocTest { + + @SpringBootApplication + static class SpringDocTestApp { + } +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app249.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app249.json new file mode 100644 index 000000000..0c87205e7 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app249.json @@ -0,0 +1,72 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenAPI definition", + "version": "v0" + }, + "servers": [ + { + "url": "http://localhost", + "description": "Generated server url" + } + ], + "paths": { + "/items": { + "get": { + "tags": [ + "hello-controller" + ], + "operationId": "list", + "parameters": [ + { + "name": "firstNames", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "middleNames", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "phoneNumbers", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "pattern": "^\\d+$", + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": {} +} diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app249.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app249.json new file mode 100644 index 000000000..8fb710062 --- /dev/null +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app249.json @@ -0,0 +1,64 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "OpenAPI definition", + "version" : "v0" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/items" : { + "get" : { + "tags" : [ "hello-controller" ], + "operationId" : "list", + "parameters" : [ { + "name" : "firstNames", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, { + "name" : "middleNames", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, { + "name" : "phoneNumbers", + "in" : "query", + "required" : false, + "schema" : { + "type" : "array", + "items" : { + "type" : "string", + "pattern" : "^\\d+$" + } + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "components" : { } +}