Repository: springdoc/springdoc-openapi (java)

Issue:
Support RequestBody as a meta-annotation
**Problem:**
 
`io.swagger.v3.oas.annotations.parameters.RequestBody` has three targets it can be used on:
- METHOD
- PARAMETER
- ANNOTATION_TYPE
 
When springdoc resolves annotation in `AbstractRequestService.isRequestBodyParam()`, it checks if there's such annotation on a parameter directly:
 
```
methodParameter.getParameterAnnotation(RequestBody.class) != null
```
 
or on a method:
```
AnnotatedElementUtils.findMergedAnnotation(methodParameter.getMethod(), RequestBody.class) != null
```
 
but it does not cover `ANNOTATION_TYPE`, so any annotation that would use `RequestBody` as a meta-annotation won't work. This is my case, as I have a special annotation for the request body that is not native to Spring.
 
**Workaround:**
 
Use two annotations:
```
@MyRequestBody @io.swagger.v3.oas.annotations.parameters.RequestBody
```
I use full path because the name is the same between one in  Swagger and one in Spring, and there are mix-ups.
 
**Solution:**
 
Check if `RequestBody` is a meta-annotation:
```
AnnotatedElementUtils.findMergedAnnotation(methodParameter.getParameter(), RequestBody.class) != null
```
This should cover both `PARAMETER` and `ANNOTATION_TYPE` cases.

Pull request:
Added support for RequestBody as a meta-annotation
The change uses advanced `AnnotatedElementUtils` to find annotation instead of simple `getParameterAnnotation`.

Resolves #2759

Patch:
diff --git a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java
index 3313f65fc..a2a7d9b45 100644
--- a/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java
+++ b/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/AbstractRequestService.java
@@ -618,16 +618,20 @@ public abstract class AbstractRequestService {
 	 */
 	public void applyBeanValidatorAnnotations(final RequestBody requestBody, final List<Annotation> annotations, boolean isOptional) {
 		Map<String, Annotation> annos = new HashMap<>();
-		boolean requestBodyRequired = false;
+		boolean springRequestBodyRequired = false;
+		boolean swaggerRequestBodyRequired = false;
 		if (!CollectionUtils.isEmpty(annotations)) {
 			annotations.forEach(annotation -> annos.put(annotation.annotationType().getSimpleName(), annotation));
-			requestBodyRequired = annotations.stream()
+			springRequestBodyRequired = annotations.stream()
 					.filter(annotation -> org.springframework.web.bind.annotation.RequestBody.class.equals(annotation.annotationType()))
 					.anyMatch(annotation -> ((org.springframework.web.bind.annotation.RequestBody) annotation).required());
+			swaggerRequestBodyRequired = annotations.stream()
+					.filter(annotation -> io.swagger.v3.oas.annotations.parameters.RequestBody.class.equals(annotation.annotationType()))
+					.anyMatch(annotation -> ((io.swagger.v3.oas.annotations.parameters.RequestBody) annotation).required());
 		}
 		boolean validationExists = Arrays.stream(ANNOTATIONS_FOR_REQUIRED).anyMatch(annos::containsKey);
 
-		if (validationExists || (!isOptional && requestBodyRequired))
+		if (validationExists || (!isOptional && (springRequestBodyRequired || swaggerRequestBodyRequired)))
 			requestBody.setRequired(true);
 		Content content = requestBody.getContent();
 		for (MediaType mediaType : content.values()) {
@@ -766,14 +770,25 @@ public abstract class AbstractRequestService {
 
 		return (isBodyAllowed && (parameterInfo.getParameterModel() == null || parameterInfo.getParameterModel().getIn() == null) && !delegatingMethodParameter.isParameterObject())
 				&&
-				((methodParameter.getParameterAnnotation(io.swagger.v3.oas.annotations.parameters.RequestBody.class) != null
-						|| methodParameter.getParameterAnnotation(org.springframework.web.bind.annotation.RequestBody.class) != null
-						|| AnnotatedElementUtils.findMergedAnnotation(Objects.requireNonNull(methodParameter.getMethod()), io.swagger.v3.oas.annotations.parameters.RequestBody.class) != null)
+				(checkRequestBodyAnnotation(methodParameter)
 						|| checkOperationRequestBody(methodParameter)
 						|| checkFile(methodParameter)
 						|| Arrays.asList(methodAttributes.getMethodConsumes()).contains(MULTIPART_FORM_DATA_VALUE));
 	}
 
+	/**
+ 	 * Checks whether Swagger's or Spring's RequestBody annotation is present on a parameter or method
+   	 *
+	 * @param methodParameter the method parameter
+	 * @return the boolean
+ 	 */
+	private boolean checkRequestBodyAnnotation(MethodParameter methodParameter) {
+		return methodParameter.hasParameterAnnotation(org.springframework.web.bind.annotation.RequestBody.class)
+				|| methodParameter.hasParameterAnnotation(io.swagger.v3.oas.annotations.parameters.RequestBody.class)
+				|| AnnotatedElementUtils.isAnnotated(Objects.requireNonNull(methodParameter.getParameter()), io.swagger.v3.oas.annotations.parameters.RequestBody.class)
+				|| AnnotatedElementUtils.isAnnotated(Objects.requireNonNull(methodParameter.getMethod()), io.swagger.v3.oas.annotations.parameters.RequestBody.class);
+	}
+	
 	/**
 	 * Check file boolean.
 	 *


Test patch:
diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230Controller.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230Controller.java
new file mode 100644
index 000000000..de37d116f
--- /dev/null
+++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230Controller.java
@@ -0,0 +1,22 @@
+package test.org.springdoc.api.v30.app230;
+
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author edudar
+ */
+@RestController
+@RequestMapping
+public class App230Controller {
+
+	@PostMapping("/")
+	public String swaggerTest(@App230RequestBody MyRequest myRequest) {
+		return null;
+	}
+
+	public record MyRequest(int id) {
+	}
+
+}
diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230RequestBody.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230RequestBody.java
new file mode 100644
index 000000000..a275a0a7b
--- /dev/null
+++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/App230RequestBody.java
@@ -0,0 +1,21 @@
+package test.org.springdoc.api.v30.app230;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+
+/**
+ * @author edudar
+ */
+@Target({PARAMETER, METHOD, ANNOTATION_TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+@RequestBody(required = true)
+@interface App230RequestBody {
+    
+}
diff --git a/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/SpringDocApp230Test.java b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/SpringDocApp230Test.java
new file mode 100644
index 000000000..981e3e664
--- /dev/null
+++ b/springdoc-openapi-starter-webmvc-api/src/test/java/test/org/springdoc/api/v30/app230/SpringDocApp230Test.java
@@ -0,0 +1,40 @@
+/*
+ *
+ *  *
+ *  *  *
+ *  *  *  *
+ *  *  *  *  *
+ *  *  *  *  *  * Copyright 2019-2024 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.app230;
+
+import test.org.springdoc.api.v30.AbstractSpringDocV30Test;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author edudar
+ */
+public class SpringDocApp230Test extends AbstractSpringDocV30Test {
+
+	@SpringBootApplication
+	static class SpringDocTestApp {}
+}
diff --git a/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app230.json b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app230.json
new file mode 100644
index 000000000..5d328c26d
--- /dev/null
+++ b/springdoc-openapi-starter-webmvc-api/src/test/resources/results/3.0.1/app230.json
@@ -0,0 +1,58 @@
+{
+  "openapi": "3.0.1",
+  "info": {
+    "title": "OpenAPI definition",
+    "version": "v0"
+  },
+  "servers": [
+    {
+      "url": "http://localhost",
+      "description": "Generated server url"
+    }
+  ],
+  "paths": {
+    "/": {
+      "post": {
+        "tags": [
+          "app-230-controller"
+        ],
+        "operationId": "swaggerTest",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/MyRequest"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "*/*": {
+                "schema": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "MyRequest": {
+        "properties": {
+          "id": {
+            "format": "int32",
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      }
+    }
+  }
+}