From b17f88b4836560d7ec22777090fb76631869f963 Mon Sep 17 00:00:00 2001 From: Mattias-Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:02:02 +0200 Subject: [PATCH] feat: add support for reading from Optionals with customizers --- CHANGELOG.md | 4 ++ .../extractor/DelegatingMethodParameter.java | 50 +++++++++++++++++-- .../api/v30/app70/HelloController.java | 6 ++- .../app70/customizer/CustomizedParameter.java | 3 ++ .../app70/customizer/CustomizedProperty.java | 3 ++ .../api/v30/app70/model/ApiType.java | 5 ++ .../api/v31/app70/HelloController.java | 6 ++- .../app70/customizer/CustomizedParameter.java | 3 ++ .../app70/customizer/CustomizedProperty.java | 3 ++ .../api/v31/app70/model/ApiType.java | 5 ++ .../test/resources/results/3.0.1/app70.json | 14 ++++++ .../test/resources/results/3.1.0/app70.json | 15 ++++++ 12 files changed, 111 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dffa8870..2d01f42919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The MCP dashboard no longer pre-fills the OAuth2 token endpoint, client id and client secret. The form shows hints instead, and warns when the token endpoint is not HTTPS +### Added + +- `ParameterCustomizer` now receive the type-use annotation data declared on a value wrapped in an `Optional`, so annotations such as `Optional<@MyAnnotation Foo>` can be read natively by it + ## [3.1.1] - 2026-09-06 ### Security diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java index 0cad656308..009f6e44b5 100644 --- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java +++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/DelegatingMethodParameter.java @@ -27,11 +27,14 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.AnnotatedType; import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Field; import java.lang.reflect.Member; import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; @@ -115,7 +118,13 @@ public class DelegatingMethodParameter extends MethodParameter { * @param field the field * @param isNotRequired the is required */ - DelegatingMethodParameter(MethodParameter delegate, String parameterName, Annotation[] additionalParameterAnnotations, Annotation[] methodAnnotations, boolean isParameterObject, Field field, boolean isNotRequired) { + DelegatingMethodParameter(MethodParameter delegate, + String parameterName, + Annotation[] additionalParameterAnnotations, + Annotation[] methodAnnotations, + boolean isParameterObject, + Field field, + boolean isNotRequired) { super(delegate); this.delegate = delegate; this.field = field; @@ -137,7 +146,9 @@ public class DelegatingMethodParameter extends MethodParameter { * @return the method parameter [ ] */ public static MethodParameter[] customize(String[] pNames, MethodParameter[] parameters, - Optional> optionalDelegatingMethodParameterCustomizers, MethodParameterPojoExtractor methodParameterPojoExtractor, boolean defaultFlatParamObject) { + Optional> optionalDelegatingMethodParameterCustomizers, + MethodParameterPojoExtractor methodParameterPojoExtractor, + boolean defaultFlatParamObject) { List explodedParameters = new ArrayList<>(); for (int i = 0; i < parameters.length; ++i) { MethodParameter p = parameters[i]; @@ -155,7 +166,7 @@ public static MethodParameter[] customize(String[] pNames, MethodParameter[] par } else { String name = pNames != null ? pNames[i] : p.getParameterName(); - explodedParameters.add(new DelegatingMethodParameter(p, name, null, null, false, null, false)); + explodedParameters.add(new DelegatingMethodParameter(p, name, getTypeUseAnnotations(p), null, false, null, false)); } } return explodedParameters.toArray(new MethodParameter[0]); @@ -166,7 +177,8 @@ public static MethodParameter[] customize(String[] pNames, MethodParameter[] par * given containing class. * * @param methodParameter the method parameter - * @param containingClass a specific containing class (potentially a subclass of the declaring class, e.g. substituting a type variable) A copy of spring withContainingClass, to keep compatibility with older spring versions + * @param containingClass a specific containing class (potentially a subclass of the declaring class, e.g. substituting a type variable). + * A copy of spring withContainingClass, to keep compatibility with older spring versions * @return the method parameter * @see #getParameterType() #getParameterType()#getParameterType()#getParameterType()#getParameterType() */ @@ -318,4 +330,34 @@ public boolean isParameterObject() { public Field getField() { return field; } + + /** + * Collects the type-use annotations declared on a parameter's type so that customizers can natively + * read annotation data placed on the type. Annotations declared directly on the parameter type + * (for example {@code @MyAnnotation Foo}) are always collected. Annotations declared on the wrapped + * type of an {@link Optional} (for example {@code Optional<@MyAnnotation Foo>}) are also collected, + * since an {@code Optional} is unwrapped to its element type. Annotations on the element type of other + * container types (such as {@link java.util.List}) are intentionally not collected, as they apply to + * the items' schema rather than to the parameter itself. + * + * @param methodParameter the method parameter + * @return the type-use annotations, or {@code null} if none were found + */ + @Nullable + private static Annotation[] getTypeUseAnnotations(MethodParameter methodParameter) { + int index = methodParameter.getParameterIndex(); + if (index < 0) + return null; + Parameter[] parameters = methodParameter.getExecutable().getParameters(); + if (index >= parameters.length) + return null; + AnnotatedType annotatedType = parameters[index].getAnnotatedType(); + List annotations = new ArrayList<>(Arrays.asList(annotatedType.getAnnotations())); + if (Optional.class.isAssignableFrom(methodParameter.getParameterType()) + && annotatedType instanceof AnnotatedParameterizedType annotatedParameterizedType) { + for (AnnotatedType typeArgument : annotatedParameterizedType.getAnnotatedActualTypeArguments()) + annotations.addAll(Arrays.asList(typeArgument.getAnnotations())); + } + return annotations.isEmpty() ? null : annotations.toArray(new Annotation[0]); + } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/HelloController.java index e90a81d68c..4e7b22b0bd 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/HelloController.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/HelloController.java @@ -32,15 +32,19 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @RestController public class HelloController { @CustomizedOperation @Operation(description = "Some operation") @GetMapping("/example/{test}") - public ApiType test(@PathVariable @CustomizedParameter @Parameter(description = "Parameter description") String test) { + public ApiType test(@PathVariable @CustomizedParameter @Parameter(description = "Parameter description") String test, + @RequestParam @Parameter(description = "Parameter description") Optional<@CustomizedParameter String> optionalTest) { return new ApiType(); } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedParameter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedParameter.java index 16a3832989..b7c5f9e454 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedParameter.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedParameter.java @@ -24,9 +24,12 @@ package test.org.springdoc.api.v30.app70.customizer; +import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +@Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) public @interface CustomizedParameter { String addition() default "customized parameter!"; diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedProperty.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedProperty.java index 8670e99d06..7fb6b2df28 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedProperty.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/customizer/CustomizedProperty.java @@ -24,9 +24,12 @@ package test.org.springdoc.api.v30.app70.customizer; +import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +@Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) public @interface CustomizedProperty { String addition() default "customized property!"; diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/model/ApiType.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/model/ApiType.java index 002f759b80..55727de45b 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/model/ApiType.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app70/model/ApiType.java @@ -25,6 +25,7 @@ package test.org.springdoc.api.v30.app70.model; import java.time.Duration; +import java.util.Optional; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; @@ -36,4 +37,8 @@ public class ApiType { @JsonProperty("someProperty") private Duration someProperty; + @Schema(description = "Test description") + @JsonProperty("someOptionalProperty") + private Optional<@CustomizedProperty Duration> someOptionalProperty; + } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/HelloController.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/HelloController.java index fc82e122b1..840ee05075 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/HelloController.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/HelloController.java @@ -32,15 +32,19 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @RestController public class HelloController { @CustomizedOperation @Operation(description = "Some operation") @GetMapping("/example/{test}") - public ApiType test(@PathVariable @CustomizedParameter @Parameter(description = "Parameter description") String test) { + public ApiType test(@PathVariable @CustomizedParameter @Parameter(description = "Parameter description") String test, + @RequestParam @Parameter(description = "Parameter description") Optional<@CustomizedParameter String> optionalTest) { return new ApiType(); } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedParameter.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedParameter.java index b9f18fdd57..c177a278c0 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedParameter.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedParameter.java @@ -24,9 +24,12 @@ package test.org.springdoc.api.v31.app70.customizer; +import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +@Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) public @interface CustomizedParameter { String addition() default "customized parameter!"; diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedProperty.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedProperty.java index bbef08146d..a459f0264d 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedProperty.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/customizer/CustomizedProperty.java @@ -24,9 +24,12 @@ package test.org.springdoc.api.v31.app70.customizer; +import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +@Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.RUNTIME) public @interface CustomizedProperty { String addition() default "customized property!"; diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/model/ApiType.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/model/ApiType.java index 426abc88fa..c2da0dfc2f 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/model/ApiType.java +++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v31/app70/model/ApiType.java @@ -25,6 +25,7 @@ package test.org.springdoc.api.v31.app70.model; import java.time.Duration; +import java.util.Optional; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; @@ -36,4 +37,8 @@ public class ApiType { @JsonProperty("someProperty") private Duration someProperty; + @Schema(description = "Test description") + @JsonProperty("someOptionalProperty") + private Optional<@CustomizedProperty Duration> someOptionalProperty; + } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app70.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app70.json index 560203e847..87c3e8f41b 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app70.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app70.json @@ -27,6 +27,15 @@ "schema": { "type": "string" } + }, + { + "name": "optionalTest", + "in": "query", + "description": "Parameter description, customized parameter!", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -53,6 +62,11 @@ "type": "string", "properties": {}, "format": "duration" + }, + "someOptionalProperty": { + "type": "string", + "properties": {}, + "format": "duration" } } } diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app70.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app70.json index 1bc5135c83..e5e6dbc000 100644 --- a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app70.json +++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.1.0/app70.json @@ -27,6 +27,15 @@ "schema": { "type": "string" } + }, + { + "name": "optionalTest", + "in": "query", + "description": "Parameter description, customized parameter!", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -54,6 +63,12 @@ "format": "duration", "description": "Test description", "properties": {} + }, + "someOptionalProperty": { + "type": "string", + "format": "duration", + "description": "Test description", + "properties": {} } } }