Skip to content

Commit

Permalink
feat(spring): support SpringDoc @ParameterObject (#1823)
Browse files Browse the repository at this point in the history
* feat(springdoc-parameter-object): support with jakarta-query-param

* Remove need for Jakarta annotation for Spring param bean on GET methods

Signed-off-by: Michael Edgar <[email protected]>

* Resolve some warnings in Spring extension

Signed-off-by: Michael Edgar <[email protected]>

---------

Signed-off-by: Michael Edgar <[email protected]>
Co-authored-by: Diego Ramp (u125015) <[email protected]>
Co-authored-by: Michael Edgar <[email protected]>
  • Loading branch information
3 people authored Dec 8, 2024
1 parent 58824e2 commit 955121c
Show file tree
Hide file tree
Showing 20 changed files with 194 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1467,7 +1467,7 @@ boolean nameAndStyleMatch(ParameterContext context, ParameterContextKey key) {
* @param target annotated item. Only method and method parameter targets.
* @return the MethodInfo associated with the target, or null if target is not a method or parameter.
*/
static MethodInfo targetMethod(AnnotationTarget target) {
protected static MethodInfo targetMethod(AnnotationTarget target) {
if (target.kind() == Kind.METHOD) {
return target.asMethod();
}
Expand Down
12 changes: 12 additions & 0 deletions extension-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<properties>
<version.spring>6.1.13</version.spring>
<version.spring-security>6.4.1</version.spring-security>
<version.springdoc>1.8.0</version.springdoc>
</properties>

<dependencyManagement>
Expand All @@ -25,6 +26,12 @@
<version>${version.spring}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-common</artifactId>
<version>${version.springdoc}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
Expand Down Expand Up @@ -81,6 +88,11 @@
<artifactId>jakarta.servlet-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-common</artifactId>
<scope>test</scope>
</dependency>

<!-- Depend on core tests -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,45 +242,14 @@ private void processControllerMethods(final ClassInfo resourceClass,

for (MethodInfo methodInfo : getResourceMethods(context, resourceClass)) {
if (!methodInfo.annotations().isEmpty()) {
// Try @XXXMapping annotations
for (DotName validMethodAnnotations : SpringConstants.HTTP_METHODS) {
if (methodInfo.hasAnnotation(validMethodAnnotations)) {
String toHttpMethod = toHttpMethod(validMethodAnnotations);
PathItem.HttpMethod httpMethod = PathItem.HttpMethod.valueOf(toHttpMethod);
processControllerMethod(resourceClass, methodInfo, httpMethod, openApi, tagRefs,
locatorPathParameters);

}
for (PathItem.HttpMethod httpMethod : SpringSupport.getHttpMethods(methodInfo)) {
processControllerMethod(resourceClass, methodInfo, httpMethod, openApi, tagRefs,
locatorPathParameters);
}

// Try @RequestMapping
if (methodInfo.hasAnnotation(SpringConstants.REQUEST_MAPPING)) {
AnnotationInstance requestMappingAnnotation = methodInfo.annotation(SpringConstants.REQUEST_MAPPING);
AnnotationValue methodValue = requestMappingAnnotation.value("method");
if (methodValue != null) {
String[] enumArray = methodValue.asEnumArray();
for (String enumValue : enumArray) {
if (enumValue != null) {
PathItem.HttpMethod httpMethod = PathItem.HttpMethod.valueOf(enumValue.toUpperCase());
processControllerMethod(resourceClass, methodInfo, httpMethod, openApi, tagRefs,
locatorPathParameters);
}
}
} else {
// TODO: Default ?
}
}

}
}
}

private String toHttpMethod(DotName dotname) {
String className = dotname.withoutPackagePrefix();
className = className.replace("Mapping", "");
return className.toUpperCase();
}

/**
* Process a single Spring method to produce an OpenAPI Operation.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public class SpringConstants {
.map(simpleName -> DotName.createComponentized(prefix, simpleName)))
.collect(Collectors.toSet());

static final DotName PARAMETER_OBJECT = DotName.createSimple("org.springdoc.api.annotations.ParameterObject");

public static final Set<DotName> MULTIPART_OUTPUTS = Collections
.unmodifiableSet(new HashSet<>(Arrays.asList(MUTIPART_FILE)));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public enum SpringParameter {
MATRIX_PARAM(SpringConstants.MATRIX_PARAM, Parameter.In.PATH, Parameter.Style.MATRIX, Parameter.Style.MATRIX),
QUERY_PARAM(SpringConstants.QUERY_PARAM, Parameter.In.QUERY, null, Parameter.Style.FORM),
HEADER_PARAM(SpringConstants.HEADER_PARAM, Parameter.In.HEADER, null, Parameter.Style.SIMPLE),
COOKIE_PARAM(SpringConstants.COOKIE_PARAM, Parameter.In.COOKIE, null, Parameter.Style.FORM);
COOKIE_PARAM(SpringConstants.COOKIE_PARAM, Parameter.In.COOKIE, null, Parameter.Style.FORM),
// SpringDoc annotation to indicate a bean with parameters (like Jakarta @BeanParam)
PARAMETER_OBJECT(SpringConstants.PARAMETER_OBJECT, null, null, null);

//BEAN_PARAM(SpringConstants.BEAN_PARAM, null, null, null),
//FORM_PARAM(SpringConstants.FORM_PARAM, null, Parameter.Style.FORM, Parameter.Style.FORM),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.function.Function;
import java.util.regex.Pattern;

import org.eclipse.microprofile.openapi.models.PathItem;
import org.eclipse.microprofile.openapi.models.parameters.Parameter;
import org.eclipse.microprofile.openapi.models.parameters.Parameter.Style;
import org.jboss.jandex.AnnotationInstance;
Expand All @@ -20,6 +21,7 @@
import io.smallrye.openapi.runtime.io.Names;
import io.smallrye.openapi.runtime.scanner.AnnotationScannerExtension;
import io.smallrye.openapi.runtime.scanner.ResourceParameters;
import io.smallrye.openapi.runtime.scanner.dataobject.TypeResolver;
import io.smallrye.openapi.runtime.scanner.spi.AbstractParameterProcessor;
import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerContext;
import io.smallrye.openapi.runtime.scanner.spi.FrameworkParameter;
Expand Down Expand Up @@ -122,20 +124,39 @@ protected void readAnnotatedType(AnnotationInstance annotation, AnnotationInstan
// }
} else if (frameworkParam.location != null) {
readFrameworkParameter(annotation, frameworkParam, overriddenParametersOnly);
} else if (target != null) {
// This is a @BeanParam or a RESTEasy @MultipartForm
} else if (target != null && annotatesHttpGET(target)) {
// This is a SpringDoc @ParameterObject
setMediaType(frameworkParam);
targetType = TypeUtil.unwrapType(targetType);

if (targetType != null) {
ClassInfo beanParam = index.getClassByName(targetType.name());
readParameters(beanParam, annotation, overriddenParametersOnly);

/*
* Since the properties of the bean are probably not annotated (supported in Spring),
* here we process them with a generated Spring @RequestParam annotation attached.
*/
for (var entry : TypeResolver.getAllFields(scannerContext, targetType, beanParam, null).entrySet()) {
var syntheticQuery = AnnotationInstance.builder(SpringConstants.QUERY_PARAM)
.buildWithTarget(entry.getValue().getAnnotationTarget());
readAnnotatedType(syntheticQuery, beanParamAnnotation, overriddenParametersOnly);
}
}
}
}
}
}

static boolean annotatesHttpGET(AnnotationTarget target) {
MethodInfo resourceMethod = targetMethod(target);

if (resourceMethod != null) {
return SpringSupport.getHttpMethods(resourceMethod).contains(PathItem.HttpMethod.GET);
}

return false;
}

@Override
protected Set<DotName> getDefaultAnnotationNames() {
return Collections.singleton(SpringConstants.QUERY_PARAM);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.smallrye.openapi.spring;

import java.util.LinkedHashSet;
import java.util.Set;

import org.eclipse.microprofile.openapi.models.PathItem;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.DotName;
import org.jboss.jandex.MethodInfo;

class SpringSupport {

private SpringSupport() {
}

static Set<PathItem.HttpMethod> getHttpMethods(MethodInfo methodInfo) {
Set<PathItem.HttpMethod> methods = new LinkedHashSet<>();

// Try @XXXMapping annotations
for (DotName validMethodAnnotations : SpringConstants.HTTP_METHODS) {
if (methodInfo.hasAnnotation(validMethodAnnotations)) {
String toHttpMethod = toHttpMethod(validMethodAnnotations);
methods.add(PathItem.HttpMethod.valueOf(toHttpMethod));
}
}

// Try @RequestMapping
if (methodInfo.hasAnnotation(SpringConstants.REQUEST_MAPPING)) {
AnnotationInstance requestMappingAnnotation = methodInfo.annotation(SpringConstants.REQUEST_MAPPING);
AnnotationValue methodValue = requestMappingAnnotation.value("method");

if (methodValue != null) {
String[] enumArray = methodValue.asEnumArray();
for (String enumValue : enumArray) {
if (enumValue != null) {
methods.add(PathItem.HttpMethod.valueOf(enumValue.toUpperCase()));
}
}
} else {
// Default ?
}
}

return methods;
}

private static String toHttpMethod(DotName dotname) {
String className = dotname.withoutPackagePrefix();
className = className.replace("Mapping", "");
return className.toUpperCase();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;

import test.io.smallrye.openapi.runtime.scanner.entities.Greeting;
import test.io.smallrye.openapi.runtime.scanner.entities.GreetingParam;
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingDeleteController;
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingDeleteControllerAlt;
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingGetController;
Expand All @@ -17,8 +18,6 @@
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingPostControllerAlt;
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingPutController;
import test.io.smallrye.openapi.runtime.scanner.resources.GreetingPutControllerAlt;
import test.io.smallrye.openapi.runtime.scanner.resources.javax.GreetingPostControllerWithServletContext;
import test.io.smallrye.openapi.runtime.scanner.resources.javax.GreetingPutControllerWithServletContext;

/**
* Basic Spring annotation scanning
Expand All @@ -35,7 +34,7 @@ class SpringAnnotationScannerTest extends SpringDataObjectScannerTestBase {
*/
@Test
void testBasicGetSpringDefinitionScanning() throws IOException, JSONException {
Index i = indexOf(GreetingGetController.class, Greeting.class);
Index i = indexOf(GreetingGetController.class, Greeting.class, GreetingParam.class);
OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(emptyConfig(), i);

OpenAPI result = scanner.scan();
Expand All @@ -54,7 +53,7 @@ void testBasicGetSpringDefinitionScanning() throws IOException, JSONException {
*/
@Test
void testBasicSpringDefinitionScanningAlt() throws IOException, JSONException {
Index i = indexOf(GreetingGetControllerAlt.class, Greeting.class);
Index i = indexOf(GreetingGetControllerAlt.class, Greeting.class, GreetingParam.class);
OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(emptyConfig(), i);

OpenAPI result = scanner.scan();
Expand All @@ -73,7 +72,7 @@ void testBasicSpringDefinitionScanningAlt() throws IOException, JSONException {
*/
@Test
void testBasicSpringDefinitionScanningAlt2() throws IOException, JSONException {
Index i = indexOf(GreetingGetControllerAlt2.class, Greeting.class);
Index i = indexOf(GreetingGetControllerAlt2.class, Greeting.class, GreetingParam.class);
OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(emptyConfig(), i);

OpenAPI result = scanner.scan();
Expand All @@ -90,7 +89,7 @@ void testBasicSpringDefinitionScanningAlt2() throws IOException, JSONException {
*/
@Test
void testBasicPostSpringDefinitionScanning() throws IOException, JSONException {
Index i = indexOf(GreetingPostController.class, Greeting.class);
Index i = indexOf(GreetingPostController.class, Greeting.class, GreetingParam.class);
OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(emptyConfig(), i);

OpenAPI result = scanner.scan();
Expand All @@ -107,7 +106,7 @@ void testBasicPostSpringDefinitionScanning() throws IOException, JSONException {
*/
@Test
void testBasicPostSpringDefinitionScanningAlt() throws IOException, JSONException {
Index i = indexOf(GreetingPostControllerAlt.class, Greeting.class);
Index i = indexOf(GreetingPostControllerAlt.class, Greeting.class, GreetingParam.class);
OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(emptyConfig(), i);

OpenAPI result = scanner.scan();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package test.io.smallrye.openapi.runtime.scanner.entities;

public class GreetingParam {
private final String name;

public GreetingParam(String name) {
this.name = name;
}

public String getName() {
return name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class GreetingDeleteController {
// 1) Basic path var test
@DeleteMapping("/greet/{id}")
public void greet(@PathVariable(name = "id") String id) {

// No op
}

// 2) ResponseEntity without a type specified
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class GreetingDeleteControllerAlt {
// 1) Basic path var test
@RequestMapping(value = "/greet/{id}", method = RequestMethod.DELETE)
public void greet(@PathVariable(name = "id") String id) {

// No op
}

// 2) ResponseEntity without a type specified
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
import org.springdoc.api.annotations.ParameterObject;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
Expand All @@ -19,6 +20,7 @@
import org.springframework.web.bind.annotation.RestController;

import test.io.smallrye.openapi.runtime.scanner.entities.Greeting;
import test.io.smallrye.openapi.runtime.scanner.entities.GreetingParam;

/**
* Spring.
Expand Down Expand Up @@ -57,11 +59,17 @@ public Greeting helloRequestParam(@RequestParam(value = "name", required = false
return new Greeting("Hello " + name);
}

// 4a) Basic request with parameter-object test
@GetMapping("/helloParameterObject")
public Greeting helloParameterObject(@ParameterObject() GreetingParam params) {
return new Greeting("Hello " + params.getName());
}

// 5) ResponseEntity without a type specified
@SuppressWarnings("rawtypes")
@GetMapping("/helloPathVariableWithResponse/{name}")
@APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting")))
public ResponseEntity helloPathVariableWithResponse(@PathVariable(name = "name") String name) {
public ResponseEntity<Greeting> helloPathVariableWithResponse(@PathVariable(name = "name") String name) {
return ResponseEntity.ok(new Greeting("Hello " + name));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
import org.springdoc.api.annotations.ParameterObject;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -20,6 +21,7 @@
import org.springframework.web.bind.annotation.RestController;

import test.io.smallrye.openapi.runtime.scanner.entities.Greeting;
import test.io.smallrye.openapi.runtime.scanner.entities.GreetingParam;

/**
* Spring.
Expand Down Expand Up @@ -60,11 +62,17 @@ public Greeting helloRequestParam(@RequestParam(value = "name", required = false
return new Greeting("Hello " + name);
}

// 4a) Basic request with parameter-object test
@RequestMapping(value = "/helloParameterObject", method = RequestMethod.GET)
public Greeting helloParameterObject(@ParameterObject() GreetingParam params) {
return new Greeting("Hello " + params.getName());
}

// 5) ResponseEntity without a type specified
@SuppressWarnings("rawtypes")
@RequestMapping(value = "/helloPathVariableWithResponse/{name}", method = RequestMethod.GET)
@APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting")))
public ResponseEntity helloPathVariableWithResponse(@PathVariable(name = "name") String name) {
public ResponseEntity<Greeting> helloPathVariableWithResponse(@PathVariable(name = "name") String name) {
return ResponseEntity.ok(new Greeting("Hello " + name));
}

Expand Down
Loading

0 comments on commit 955121c

Please sign in to comment.