Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(spring): support SpringDoc @ParameterObject #1823

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading