From 955121cd434fd06d6460b27183c9c2d9feb39ead Mon Sep 17 00:00:00 2001 From: Diego Ramp Date: Mon, 9 Dec 2024 00:39:40 +0100 Subject: [PATCH 1/3] feat(spring): support SpringDoc `@ParameterObject` (#1823) * 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 * Resolve some warnings in Spring extension Signed-off-by: Michael Edgar --------- Signed-off-by: Michael Edgar Co-authored-by: Diego Ramp (u125015) Co-authored-by: Michael Edgar --- .../spi/AbstractParameterProcessor.java | 2 +- extension-spring/pom.xml | 12 +++++ .../spring/SpringAnnotationScanner.java | 37 ++----------- .../openapi/spring/SpringConstants.java | 2 + .../openapi/spring/SpringParameter.java | 4 +- .../spring/SpringParameterProcessor.java | 27 ++++++++-- .../openapi/spring/SpringSupport.java | 53 +++++++++++++++++++ .../scanner/SpringAnnotationScannerTest.java | 13 +++-- .../scanner/entities/GreetingParam.java | 13 +++++ .../resources/GreetingDeleteController.java | 2 +- .../GreetingDeleteControllerAlt.java | 2 +- .../resources/GreetingGetController.java | 10 +++- .../resources/GreetingGetControllerAlt.java | 10 +++- .../resources/GreetingGetControllerAlt2.java | 10 +++- .../GreetingParameterobjectController.java | 21 ++++++++ .../resources/GreetingPostController.java | 2 +- .../resources/GreetingPostControllerAlt.java | 2 +- .../resources/GreetingPutController.java | 2 +- .../resources/GreetingPutControllerAlt.java | 2 +- ....testBasicSpringGetDefinitionScanning.json | 23 ++++++++ 20 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 extension-spring/src/main/java/io/smallrye/openapi/spring/SpringSupport.java create mode 100644 extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/entities/GreetingParam.java create mode 100644 extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingParameterobjectController.java diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractParameterProcessor.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractParameterProcessor.java index 505140feb..818d9a43e 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractParameterProcessor.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AbstractParameterProcessor.java @@ -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(); } diff --git a/extension-spring/pom.xml b/extension-spring/pom.xml index 5fca3154f..7c85bcc88 100644 --- a/extension-spring/pom.xml +++ b/extension-spring/pom.xml @@ -15,6 +15,7 @@ 6.1.13 6.4.1 + 1.8.0 @@ -25,6 +26,12 @@ ${version.spring} test + + org.springdoc + springdoc-openapi-common + ${version.springdoc} + test + @@ -81,6 +88,11 @@ jakarta.servlet-api test + + org.springdoc + springdoc-openapi-common + test + diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java index 3e964bfe1..f3864ecf0 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringAnnotationScanner.java @@ -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. * diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java index 6dedd7c1e..29a65bd59 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringConstants.java @@ -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 MULTIPART_OUTPUTS = Collections .unmodifiableSet(new HashSet<>(Arrays.asList(MUTIPART_FILE))); diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameter.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameter.java index 670d6c717..ee90f6f24 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameter.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameter.java @@ -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), diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameterProcessor.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameterProcessor.java index 9745642ff..4af136cf9 100644 --- a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameterProcessor.java +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringParameterProcessor.java @@ -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; @@ -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; @@ -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 getDefaultAnnotationNames() { return Collections.singleton(SpringConstants.QUERY_PARAM); diff --git a/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringSupport.java b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringSupport.java new file mode 100644 index 000000000..7ecdb95a1 --- /dev/null +++ b/extension-spring/src/main/java/io/smallrye/openapi/spring/SpringSupport.java @@ -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 getHttpMethods(MethodInfo methodInfo) { + Set 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(); + } +} diff --git a/extension-spring/src/test/java/io/smallrye/openapi/runtime/scanner/SpringAnnotationScannerTest.java b/extension-spring/src/test/java/io/smallrye/openapi/runtime/scanner/SpringAnnotationScannerTest.java index e65c24231..bf9aa44c7 100644 --- a/extension-spring/src/test/java/io/smallrye/openapi/runtime/scanner/SpringAnnotationScannerTest.java +++ b/extension-spring/src/test/java/io/smallrye/openapi/runtime/scanner/SpringAnnotationScannerTest.java @@ -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; @@ -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 @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/entities/GreetingParam.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/entities/GreetingParam.java new file mode 100644 index 000000000..844aa2a7e --- /dev/null +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/entities/GreetingParam.java @@ -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; + } +} \ No newline at end of file diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java index 01b644eae..78c000c72 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteController.java @@ -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 diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java index 6093653c8..a3f58495f 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingDeleteControllerAlt.java @@ -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 diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetController.java index 1ac661da4..832862945 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetController.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetController.java @@ -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; @@ -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. @@ -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 helloPathVariableWithResponse(@PathVariable(name = "name") String name) { return ResponseEntity.ok(new Greeting("Hello " + name)); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt.java index dcc9dfaa6..6c02f9a5e 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt.java @@ -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; @@ -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. @@ -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 helloPathVariableWithResponse(@PathVariable(name = "name") String name) { return ResponseEntity.ok(new Greeting("Hello " + name)); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt2.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt2.java index 2f62a6da8..40537ab3d 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt2.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingGetControllerAlt2.java @@ -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; @@ -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. @@ -59,11 +61,17 @@ public Greeting helloRequestParam(@RequestParam(value = "name", required = false return new Greeting("Hello " + name); } + // 4a) Basic request with parameter-object test + @RequestMapping(path = "/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(path = "/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 helloPathVariableWithResponse(@PathVariable(name = "name") String name) { return ResponseEntity.ok(new Greeting("Hello " + name)); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingParameterobjectController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingParameterobjectController.java new file mode 100644 index 000000000..05a506e7f --- /dev/null +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingParameterobjectController.java @@ -0,0 +1,21 @@ +package test.io.smallrye.openapi.runtime.scanner.resources; + +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +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; + +@RestController +@RequestMapping(value = "/greeting-with-params", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) +public class GreetingParameterobjectController { + + @GetMapping("/hello") + public Greeting hello(@ParameterObject() GreetingParam params) { + return new Greeting("Hello " + params.getName()); + } + +} diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostController.java index dfe854989..17f68518e 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostController.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostController.java @@ -32,7 +32,7 @@ public Greeting greet(@RequestBody Greeting greeting) { // 2) ResponseEntity without a type specified @PostMapping("/greetWithResponse") @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting"))) - public ResponseEntity greetWithResponse(@RequestBody Greeting greeting) { + public ResponseEntity greetWithResponse(@RequestBody Greeting greeting) { return ResponseEntity.ok(greeting); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostControllerAlt.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostControllerAlt.java index fefef95f0..8b04d6019 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostControllerAlt.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPostControllerAlt.java @@ -34,7 +34,7 @@ public Greeting greet(@RequestBody Greeting greeting) { // 2) ResponseEntity without a type specified @RequestMapping(value = "/greetWithResponse", method = RequestMethod.POST) @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting"))) - public ResponseEntity greetWithResponse(@RequestBody Greeting greeting) { + public ResponseEntity greetWithResponse(@RequestBody Greeting greeting) { return ResponseEntity.ok(greeting); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutController.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutController.java index 4924d38b2..f789ceada 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutController.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutController.java @@ -33,7 +33,7 @@ public Greeting greet(@RequestBody Greeting greeting, @PathVariable(name = "id") // 2) ResponseEntity without a type specified @PutMapping("/greetWithResponse/{id}") @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting"))) - public ResponseEntity greetWithResponse(@RequestBody Greeting greeting, @PathVariable(name = "id") String id) { + public ResponseEntity greetWithResponse(@RequestBody Greeting greeting, @PathVariable(name = "id") String id) { return ResponseEntity.ok(greeting); } diff --git a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutControllerAlt.java b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutControllerAlt.java index a5a5e2f85..85502804a 100644 --- a/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutControllerAlt.java +++ b/extension-spring/src/test/java/test/io/smallrye/openapi/runtime/scanner/resources/GreetingPutControllerAlt.java @@ -33,7 +33,7 @@ public Greeting greet(@RequestBody Greeting greeting, @PathVariable(name = "id") // 2) ResponseEntity without a type specified @RequestMapping(value = "/greetWithResponse/{id}", method = RequestMethod.PUT) @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(ref = "#/components/schemas/Greeting"))) - public ResponseEntity greetWithResponse(@RequestBody Greeting greeting, @PathVariable(name = "id") String id) { + public ResponseEntity greetWithResponse(@RequestBody Greeting greeting, @PathVariable(name = "id") String id) { return ResponseEntity.ok(greeting); } diff --git a/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringGetDefinitionScanning.json b/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringGetDefinitionScanning.json index 182798d4f..77ae17d48 100644 --- a/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringGetDefinitionScanning.json +++ b/extension-spring/src/test/resources/io/smallrye/openapi/runtime/scanner/resource.testBasicSpringGetDefinitionScanning.json @@ -144,6 +144,29 @@ } ] } }, + "/greeting/helloParameterObject": { + "get": { + "parameters" : [ { + "name" : "name", + "in" : "query", + "schema" : { + "type" : "string" + } + } ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Greeting" + } + } + } + } + } + } + }, "/greeting/hellosPathVariable/{name}" : { "get" : { "parameters" : [ { From eebf35344a32c7a777751cab4c763fa82102d9ed Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Sun, 8 Dec 2024 18:51:29 -0500 Subject: [PATCH 2/3] Add config to keep example/examples as distinct attributes (#2099) Signed-off-by: Michael Edgar --- README.adoc | 8 +++++ .../smallrye/openapi/api/OpenApiConfig.java | 4 +++ .../openapi/api/SmallRyeOASConfig.java | 3 ++ .../runtime/io/schema/SchemaFactory.java | 27 ++++++++++----- .../scanner/OpenApiDataObjectScanner.java | 4 +-- .../dataobject/AnnotationTargetProcessor.java | 2 +- .../scanner/dataobject/TypeProcessor.java | 6 ++-- .../openapi/runtime/util/TypeUtil.java | 17 ++++------ .../scanner/StandaloneSchemaScanTest.java | 21 ++++++++++++ ...components.schemas.example-not-merged.json | 34 +++++++++++++++++++ 10 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 core/src/test/resources/io/smallrye/openapi/runtime/scanner/components.schemas.example-not-merged.json diff --git a/README.adoc b/README.adoc index a59b6aa77..e2092cc62 100644 --- a/README.adoc +++ b/README.adoc @@ -87,3 +87,11 @@ Set to `FAIL` to abort in case of duplicate operationIds, set to `WARN` to log w mp.openapi.extensions.smallrye.maximumStaticFileSize ---- Set this value in order to change the maximum threshold for processed static files, when generating model from them. If not set, it will default to 3 MB. + +* Merge Schema Examples ++ +[source%nowrap] +---- +mp.openapi.extensions.smallrye.merge-schema-examples +---- +Set this boolean value to disable the merging of the deprecated `@Schema` `example` property into the `examples` array introduced in OAS 3.1.0. If not set, it will default to `true` the deprecated `example` will be mapped to the `examples` array in the OpenAPI model. diff --git a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java index 78edc13f2..b5f3c1c40 100644 --- a/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java +++ b/core/src/main/java/io/smallrye/openapi/api/OpenApiConfig.java @@ -302,6 +302,10 @@ default boolean removeUnusedSchemas() { return getConfigValue(SmallRyeOASConfig.SMALLRYE_REMOVE_UNUSED_SCHEMAS, Boolean.class, () -> Boolean.FALSE); } + default boolean mergeSchemaExamples() { + return getConfigValue(SmallRyeOASConfig.SMALLRYE_MERGE_SCHEMA_EXAMPLES, Boolean.class, () -> Boolean.TRUE); + } + default Integer getMaximumStaticFileSize() { return getConfigValue(SmallRyeOASConfig.MAXIMUM_STATIC_FILE_SIZE, Integer.class, () -> MAXIMUM_STATIC_FILE_SIZE_DEFAULT); diff --git a/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java b/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java index 93fb00160..931c7981b 100644 --- a/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java +++ b/core/src/main/java/io/smallrye/openapi/api/SmallRyeOASConfig.java @@ -23,6 +23,7 @@ private SmallRyeOASConfig() { private static final String SUFFIX_PROPERTY_NAMING_STRATEGY = "property-naming-strategy"; private static final String SUFFIX_SORTED_PROPERTIES_ENABLE = "sorted-properties.enable"; private static final String SUFFIX_REMOVE_UNUSED_SCHEMAS_ENABLE = "remove-unused-schemas.enable"; + private static final String SUFFIX_MERGE_SCHEMA_EXAMPLES = "merge-schema-examples"; private static final String SMALLRYE_PREFIX = OASConfig.EXTENSIONS_PREFIX + VENDOR_NAME; public static final String SMALLRYE_SCAN_DEPENDENCIES_DISABLE = SMALLRYE_PREFIX + SUFFIX_SCAN_DEPENDENCIES_DISABLE; @@ -49,6 +50,8 @@ private SmallRyeOASConfig() { public static final String SMALLRYE_REMOVE_UNUSED_SCHEMAS = SMALLRYE_PREFIX + SUFFIX_REMOVE_UNUSED_SCHEMAS_ENABLE; + public static final String SMALLRYE_MERGE_SCHEMA_EXAMPLES = SMALLRYE_PREFIX + SUFFIX_MERGE_SCHEMA_EXAMPLES; + public static final String SCAN_PROFILES = SMALLRYE_PREFIX + "scan.profiles"; public static final String SCAN_EXCLUDE_PROFILES = SMALLRYE_PREFIX + "scan.exclude.profiles"; diff --git a/core/src/main/java/io/smallrye/openapi/runtime/io/schema/SchemaFactory.java b/core/src/main/java/io/smallrye/openapi/runtime/io/schema/SchemaFactory.java index 469b6f8ac..12f7ebf5a 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/io/schema/SchemaFactory.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/io/schema/SchemaFactory.java @@ -208,19 +208,30 @@ public static Schema readSchema(final AnnotationScannerContext context, SchemaSupport.setType(schema, readSchemaType(context, annotation, schema, defaults)); Object example = parseSchemaAttr(context, annotation, SchemaConstant.PROP_EXAMPLE, defaults, type); + List examples = SchemaFactory.> readAttr(context, annotation, SchemaConstant.PROP_EXAMPLES, egs -> Arrays.stream(egs) .map(e -> parseValue(context, e, type)) .collect(Collectors.toCollection(ArrayList::new)), - defaults); - if (examples != null) { - if (example != null) { - examples.add(example); + /* + * Do not allow default examples to be added if the annotation has specified an example + * with the deprecated property. + */ + example != null ? Collections.emptyMap() : defaults); + + if (context.getConfig().mergeSchemaExamples()) { + if (examples != null) { + if (example != null) { + examples.add(example); + } + schema.setExamples(examples); + } else { + schema.setExamples(wrapInList(example)); } - schema.setExamples(examples); } else { - schema.setExamples(wrapInList(example)); + schema.setExample(example); + schema.setExamples(examples); } schema.setDefaultValue( @@ -579,7 +590,7 @@ public static Schema typeToSchema(final AnnotationScannerContext context, Type t schema = typeToSchema(context, currentScanner.get().unwrapType(type), null); } else if (TypeUtil.isTerminalType(type)) { schema = OASFactory.createSchema(); - TypeUtil.applyTypeAttributes(type, schema); + TypeUtil.applyTypeAttributes(type, schema, schemaAnnotation); schema = schemaRegistration(context, type, schema); } else if (type.kind() == Type.Kind.ARRAY) { schema = OASFactory.createSchema().addType(SchemaType.ARRAY); @@ -647,7 +658,7 @@ public static Schema enumToSchema(final AnnotationScannerContext context, Type e enumSchema = readSchema(context, enumSchema, schemaAnnotation, enumKlazz, true, defaults); } else { - TypeUtil.applyTypeAttributes(enumValueType, enumSchema); + TypeUtil.applyTypeAttributes(enumValueType, enumSchema, null); enumSchema.setEnumeration(enumeration); } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java index d6b36ba5e..c902b495e 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java @@ -182,7 +182,7 @@ public static Schema process(final AnnotationScannerContext context, Type type) */ public static Schema process(PrimitiveType primitive) { Schema primitiveSchema = OASFactory.createSchema(); - TypeUtil.applyTypeAttributes(primitive, primitiveSchema); + TypeUtil.applyTypeAttributes(primitive, primitiveSchema, null); return primitiveSchema; } @@ -197,7 +197,7 @@ Schema process() { // If top level item is simple if (TypeUtil.isTerminalType(rootClassType)) { Schema simpleSchema = OASFactory.createSchema(); - TypeUtil.applyTypeAttributes(rootClassType, simpleSchema); + TypeUtil.applyTypeAttributes(rootClassType, simpleSchema, null); return simpleSchema; } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/AnnotationTargetProcessor.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/AnnotationTargetProcessor.java index 3d8c7a593..0ca0518c0 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/AnnotationTargetProcessor.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/AnnotationTargetProcessor.java @@ -153,7 +153,7 @@ Schema processField() { // Set any default values that apply to the type schema as a result of the TypeProcessor if (!TypeUtil.isTypeOverridden(context, fieldType, schemaAnnotation)) { - TypeUtil.applyTypeAttributes(fieldType, initTypeSchema); + TypeUtil.applyTypeAttributes(fieldType, initTypeSchema, schemaAnnotation); } // The registeredTypeSchema will be a reference to typeSchema if registration occurs diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java index e14dbcbac..6155bebad 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java @@ -176,7 +176,7 @@ private Type readArrayType(ArrayType arrayType, Schema arraySchema) { } // Only use component (excludes the special name formatting for arrays). - TypeUtil.applyTypeAttributes(componentType, itemSchema); + TypeUtil.applyTypeAttributes(componentType, itemSchema, null); if (!isTerminalType(componentType) && index.containsClass(componentType)) { // If it's not a terminal type, then push for later inspection. @@ -290,7 +290,7 @@ private Schema readGenericValueType(Type valueType) { Schema valueSchema = OASFactory.createSchema(); if (isTerminalType(valueType)) { - TypeUtil.applyTypeAttributes(valueType, valueSchema); + TypeUtil.applyTypeAttributes(valueType, valueSchema, null); } else if (valueType.kind() == Kind.PARAMETERIZED_TYPE) { readParameterizedType(valueType.asParameterizedType(), valueSchema); } else { @@ -346,7 +346,7 @@ private Type resolveTypeVariable(Schema schema, Type fieldType, boolean pushToSt if (isTerminalType(resolvedType) || !index.containsClass(resolvedType)) { DataObjectLogging.logger.terminalType(resolvedType); - TypeUtil.applyTypeAttributes(resolvedType, schema); + TypeUtil.applyTypeAttributes(resolvedType, schema, null); } else if (pushToStack) { // Add resolved type to stack. pushToStack(resolvedType, schema); diff --git a/core/src/main/java/io/smallrye/openapi/runtime/util/TypeUtil.java b/core/src/main/java/io/smallrye/openapi/runtime/util/TypeUtil.java index 454dd5a1c..7ed7d583c 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/util/TypeUtil.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/util/TypeUtil.java @@ -413,22 +413,17 @@ public static boolean isTypeOverridden(AnnotationScannerContext context, Type cl * @param classType the type * @param schema a writable schema to be updated with the type's default schema attributes */ - public static void applyTypeAttributes(Type classType, Schema schema) { + @SuppressWarnings("unchecked") + public static void applyTypeAttributes(Type classType, Schema schema, AnnotationInstance schemaAnnotation) { Map properties = getTypeAttributes(classType); SchemaSupport.setType(schema, (SchemaType) properties.get(SchemaConstant.PROP_TYPE)); schema.setFormat((String) properties.get(SchemaConstant.PROP_FORMAT)); schema.setPattern((String) properties.get(SchemaConstant.PROP_PATTERN)); - schema.setExamples(wrapInList(properties.get(SchemaConstant.PROP_EXAMPLE))); - schema.setExternalDocs((ExternalDocumentation) properties.get(SchemaConstant.PROP_EXTERNAL_DOCS)); - } - - private static List wrapInList(E item) { - if (item == null) { - return null; - } else { - return Collections.singletonList(item); + if (schemaAnnotation == null || schemaAnnotation.value(SchemaConstant.PROP_EXAMPLE) == null) { + schema.setExamples((List) properties.get(SchemaConstant.PROP_EXAMPLES)); } + schema.setExternalDocs((ExternalDocumentation) properties.get(SchemaConstant.PROP_EXTERNAL_DOCS)); } /** @@ -837,7 +832,7 @@ Builder pattern(String pattern) { } Builder example(Object example) { - properties.put(SchemaConstant.PROP_EXAMPLE, example); + properties.put(SchemaConstant.PROP_EXAMPLES, Collections.singletonList(example)); return this; } diff --git a/core/src/test/java/io/smallrye/openapi/runtime/scanner/StandaloneSchemaScanTest.java b/core/src/test/java/io/smallrye/openapi/runtime/scanner/StandaloneSchemaScanTest.java index cca4618e1..08f193b3a 100644 --- a/core/src/test/java/io/smallrye/openapi/runtime/scanner/StandaloneSchemaScanTest.java +++ b/core/src/test/java/io/smallrye/openapi/runtime/scanner/StandaloneSchemaScanTest.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.lang.annotation.ElementType; import java.lang.annotation.Target; +import java.time.LocalTime; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; @@ -766,6 +767,7 @@ public String getWo() { @Test void testExceptionalExampleParsing() throws IOException, JSONException { + // All example properties convert to `examples` in the model by default @Schema(name = "Bean") class Bean { @Schema(example = "{ Looks like object, but invalid }") @@ -820,4 +822,23 @@ public void getProperty2(java.net.URL getProperty2) { assertJsonEquals("components.schemas.javabean-property-prefix.json", Bean.class, java.net.URL.class); } + + @Test + void testExampleNotMerged() throws IOException, JSONException { + @Schema(name = "Bean") + class DTO { + + @Schema(example = "Hello World") // NOSONAR + String name; + + @Schema(example = "14:45:30.987654321") // NOSONAR + LocalTime localTime; + + @Schema(example = "14:45:30.999999999", nullable = true) // NOSONAR + LocalTime localTimeNullable; + } + + assertJsonEquals("components.schemas.example-not-merged.json", + scan(config(SmallRyeOASConfig.SMALLRYE_MERGE_SCHEMA_EXAMPLES, "false"), null, new Class[] { DTO.class })); + } } diff --git a/core/src/test/resources/io/smallrye/openapi/runtime/scanner/components.schemas.example-not-merged.json b/core/src/test/resources/io/smallrye/openapi/runtime/scanner/components.schemas.example-not-merged.json new file mode 100644 index 000000000..1fed995f3 --- /dev/null +++ b/core/src/test/resources/io/smallrye/openapi/runtime/scanner/components.schemas.example-not-merged.json @@ -0,0 +1,34 @@ +{ + "openapi" : "3.1.0", + "components" : { + "schemas" : { + "Bean" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "example" : "Hello World" + }, + "localTime" : { + "type" : "string", + "format" : "local-time", + "externalDocs" : { + "description" : "As defined by 'partial-time' in RFC3339", + "url" : "https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6" + }, + "example" : "14:45:30.987654321" + }, + "localTimeNullable" : { + "type" : [ "string", "null" ], + "format" : "local-time", + "example" : "14:45:30.999999999", + "externalDocs" : { + "description" : "As defined by 'partial-time' in RFC3339", + "url" : "https://www.rfc-editor.org/rfc/rfc3339.html#section-5.6" + } + } + } + } + } + } +} From dda7cd68c3631539a0266461d933e52a2658bb0c Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Sun, 8 Dec 2024 19:02:55 -0500 Subject: [PATCH 3/3] fix: scan property schema in next iteration when properties ignored (#2100) Signed-off-by: Michael Edgar --- .../scanner/OpenApiDataObjectScanner.java | 6 +- .../scanner/dataobject/TypeProcessor.java | 2 + .../openapi/runtime/scanner/IgnoreTests.java | 91 +++++++++++++++++ .../ignore.bidirectionalIgnoreProperties.json | 99 +++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/ignore.bidirectionalIgnoreProperties.json diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java index c902b495e..e9a858b34 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiDataObjectScanner.java @@ -238,8 +238,10 @@ private void depthFirstGraphSearch() { */ Schema entrySchema = currentPathEntry.getSchema(); SchemaRegistry registry = context.getSchemaRegistry(); + AnnotationTarget currentTarget = currentPathEntry.getAnnotationTarget(); + boolean allowRegistration = !IgnoreResolver.configuresVisibility(context, currentTarget); - if (registry.hasSchema(currentType, context.getJsonViews(), null)) { + if (allowRegistration && registry.hasSchema(currentType, context.getJsonViews(), null)) { // This type has already been scanned and registered, don't do it again! entrySchema.setRef(registry.lookupRef(currentType, context.getJsonViews()).getRef()); continue; @@ -247,8 +249,6 @@ private void depthFirstGraphSearch() { ClassInfo currentClass = currentPathEntry.getClazz(); Schema currentSchema = currentPathEntry.getSchema(); - AnnotationTarget currentTarget = currentPathEntry.getAnnotationTarget(); - boolean allowRegistration = !IgnoreResolver.configuresVisibility(context, currentTarget); // First, handle class annotations (re-assign since readKlass may return new schema) currentSchema = readKlass(currentClass, currentType, currentSchema, allowRegistration); diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java index 6155bebad..244bd9abb 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/TypeProcessor.java @@ -326,6 +326,8 @@ private Schema resolveParameterizedType(Type valueType, Schema propsSchema) { if (registry.hasSchema(valueType, context.getJsonViews(), typeResolver)) { if (allowRegistration()) { propsSchema = registry.lookupRef(valueType, context.getJsonViews()); + } else { + pushToStack(valueType, propsSchema); } } else { pushToStack(valueType, propsSchema); diff --git a/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/IgnoreTests.java b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/IgnoreTests.java index ec84ba2e4..e985baf10 100644 --- a/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/IgnoreTests.java +++ b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/IgnoreTests.java @@ -1,7 +1,11 @@ package io.smallrye.openapi.runtime.scanner; import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.models.media.Schema; import org.jboss.jandex.ClassType; import org.jboss.jandex.DotName; @@ -10,6 +14,10 @@ import org.json.JSONException; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonView; + +import io.smallrye.openapi.api.SmallRyeOASConfig; import test.io.smallrye.openapi.runtime.scanner.entities.IgnoreSchemaOnFieldExample; import test.io.smallrye.openapi.runtime.scanner.entities.IgnoreTestContainer; import test.io.smallrye.openapi.runtime.scanner.entities.JsonIgnoreOnFieldExample; @@ -123,4 +131,87 @@ void testIgnore_transientField() throws IOException, JSONException { printToConsole(name.local(), result); assertJsonEquals(name.local(), "ignore.transientField.expected.json", result); } + + static class BidirectionalJsonIgnoreProperties { + static class Views { + public static class Max extends Full { + } + + public static class Full extends Ingest { + } + + public static class Ingest extends Abridged { + } + + public static class Abridged { + } + } + + @org.eclipse.microprofile.openapi.annotations.media.Schema + static class Station { + @JsonView(Views.Full.class) + private UUID id; + + @JsonView(Views.Abridged.class) + private String name; + + @JsonView(Views.Ingest.class) + @JsonIgnoreProperties("station") + @org.eclipse.microprofile.openapi.annotations.media.Schema(readOnly = true, description = "Read-only entity details (only returned/used on detail queries).") + private Set baseCollection; + } + + @org.eclipse.microprofile.openapi.annotations.media.Schema + static class Base { + @JsonView(Views.Full.class) + private UUID id; + + @JsonView(Views.Abridged.class) + private String name; + + @JsonView(Views.Abridged.class) + @JsonIgnoreProperties("baseCollection") + private Station station; + } + + @jakarta.ws.rs.Path("/base") + static class BaseResource { + @jakarta.ws.rs.GET + @jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + @JsonView(Views.Full.class) + @APIResponse(responseCode = "200", content = @Content(schema = @org.eclipse.microprofile.openapi.annotations.media.Schema(implementation = Base.class))) + public jakarta.ws.rs.core.Response getBase() { + return null; + } + } + + @jakarta.ws.rs.Path("/station") + static class StationResource { + @jakarta.ws.rs.GET + @jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + @JsonView(Views.Full.class) + @APIResponse(responseCode = "200", content = @Content(schema = @org.eclipse.microprofile.openapi.annotations.media.Schema(implementation = Station.class))) + public jakarta.ws.rs.core.Response getStation() { + return null; + } + } + } + + @Test + void testBidirectionalJsonIgnoreProperties() throws IOException, JSONException { + Class[] classes = { + BidirectionalJsonIgnoreProperties.Views.Max.class, + BidirectionalJsonIgnoreProperties.Views.Full.class, + BidirectionalJsonIgnoreProperties.Views.Ingest.class, + BidirectionalJsonIgnoreProperties.Views.Abridged.class, + BidirectionalJsonIgnoreProperties.Views.class, + BidirectionalJsonIgnoreProperties.Base.class, + BidirectionalJsonIgnoreProperties.Station.class, + BidirectionalJsonIgnoreProperties.BaseResource.class, + BidirectionalJsonIgnoreProperties.StationResource.class + }; + + assertJsonEquals("ignore.bidirectionalIgnoreProperties.json", + scan(config(SmallRyeOASConfig.SMALLRYE_REMOVE_UNUSED_SCHEMAS, "true"), null, classes)); + } } diff --git a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/ignore.bidirectionalIgnoreProperties.json b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/ignore.bidirectionalIgnoreProperties.json new file mode 100644 index 000000000..be5b4964d --- /dev/null +++ b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/ignore.bidirectionalIgnoreProperties.json @@ -0,0 +1,99 @@ +{ + "openapi" : "3.1.0", + "components" : { + "schemas" : { + "Base_Full" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid", + "pattern" : "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" + }, + "name" : { + "type" : "string" + }, + "station" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid", + "pattern" : "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" + }, + "name" : { + "type" : "string" + } + } + } + } + }, + "Station_Full" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid", + "pattern" : "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" + }, + "name" : { + "type" : "string" + }, + "baseCollection" : { + "type" : "array", + "uniqueItems" : true, + "items" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "format" : "uuid", + "pattern" : "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" + }, + "name" : { + "type" : "string" + } + } + }, + "description" : "Read-only entity details (only returned/used on detail queries).", + "readOnly" : true + } + } + } + } + }, + "paths" : { + "/base" : { + "get" : { + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Base_Full" + } + } + } + } + } + } + }, + "/station" : { + "get" : { + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Station_Full" + } + } + } + } + } + } + } + } +}