From 0ca7e43e00314b5e7e3eec6f52dc74c9d2b3a8ff Mon Sep 17 00:00:00 2001 From: altro3 Date: Tue, 6 Feb 2024 04:40:54 +0700 Subject: [PATCH] Fix convert Operation annotation with groovy --- .../AbstractOpenApiEndpointVisitor.java | 9 +- .../visitor/AbstractOpenApiVisitor.java | 4 +- .../openapi/visitor/ConvertUtils.java | 137 ++++++++++++++++-- .../visitor/OpenApiControllerVisitor.java | 2 +- .../visitor/OpenApiEndpointVisitor.java | 2 +- .../io/micronaut/openapi/visitor/Utils.java | 14 ++ .../OpenApiPojoControllerGroovySpec.groovy | 131 +++++++++++++++++ 7 files changed, 276 insertions(+), 23 deletions(-) diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java index d5edb42eb9..1d2e28d7b8 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java @@ -131,6 +131,7 @@ import static io.micronaut.openapi.visitor.SchemaUtils.isIgnoredHeader; import static io.micronaut.openapi.visitor.SchemaUtils.setOperationOnPathItem; import static io.micronaut.openapi.visitor.Utils.DEFAULT_MEDIA_TYPES; +import static io.micronaut.openapi.visitor.Utils.getMediaType; /** * A {@link io.micronaut.inject.visitor.TypeElementVisitor} the builds the Swagger model from Micronaut controllers at compile time. @@ -486,14 +487,14 @@ private void processExtraBodyParameters(VisitorContext context, HttpMethod httpM if (HttpMethod.permitsRequestBody(httpMethod) && !extraBodyParameters.isEmpty()) { if (requestBody == null) { requestBody = new RequestBody(); - Content content = new Content(); + var content = new Content(); requestBody.setContent(content); requestBody.setRequired(true); swaggerOperation.setRequestBody(requestBody); consumesMediaTypes = CollectionUtils.isEmpty(consumesMediaTypes) ? DEFAULT_MEDIA_TYPES : consumesMediaTypes; consumesMediaTypes.forEach(mediaType -> { - io.swagger.v3.oas.models.media.MediaType mt = new io.swagger.v3.oas.models.media.MediaType(); + var mt = new io.swagger.v3.oas.models.media.MediaType(); var schema = new Schema<>(); schema.setType(TYPE_OBJECT); mt.setSchema(schema); @@ -501,7 +502,7 @@ private void processExtraBodyParameters(VisitorContext context, HttpMethod httpM }); } } - if (requestBody != null && !extraBodyParameters.isEmpty()) { + if (requestBody != null && requestBody.getContent() != null && !extraBodyParameters.isEmpty()) { requestBody.getContent().forEach((mediaTypeName, mediaType) -> { var schema = mediaType.getSchema(); if (schema == null) { @@ -523,7 +524,7 @@ private void processExtraBodyParameters(VisitorContext context, HttpMethod httpM } for (TypedElement parameter : extraBodyParameters) { if (!isRequestBodySchemaSet) { - processBodyParameter(context, openAPI, javadocDescription, MediaType.of(mediaTypeName), schema, parameter); + processBodyParameter(context, openAPI, javadocDescription, getMediaType(mediaTypeName), schema, parameter); } if (mediaTypeName.equals(MediaType.MULTIPART_FORM_DATA)) { if (CollectionUtils.isNotEmpty(schema.getProperties())) { diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiVisitor.java index 5a8b32b23c..4c01374452 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiVisitor.java @@ -239,7 +239,7 @@ Optional toValue(Map values, VisitorContext context try { return Optional.ofNullable(ConvertUtils.treeToValue(node, type, context)); } catch (JsonProcessingException e) { - warn("Error converting [" + node + "]: to " + type + ": " + e.getMessage(), context); + warn("Error converting [" + node + "]: to " + type + ":\n" + Utils.printStackTrace(e), context); } return Optional.empty(); } @@ -2277,7 +2277,7 @@ private List getEnumValues(EnumElement type, String schemaType, String s try { enumValues.add(ConvertUtils.normalizeValue(jacksonValue, schemaType, schemaFormat, context)); } catch (JsonProcessingException e) { - warn("Error converting jacksonValue " + jacksonValue + " : to " + type + ": " + e.getMessage(), context, element); + warn("Error converting jacksonValue " + jacksonValue + " : to " + type + ":\n" + Utils.printStackTrace(e), context, element); enumValues.add(element.getSimpleName()); } } else { diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java b/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java index d127e050fd..1bf97687e8 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java @@ -61,10 +61,17 @@ import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.annotations.servers.ServerVariable; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.headers.Header; import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.Encoding; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; @@ -110,7 +117,7 @@ public static T toValue(Map values, VisitorContext con try { return ConvertUtils.treeToValue(node, type, context); } catch (JsonProcessingException e) { - warn("Error converting [" + node + "]: to " + type + ": " + e.getMessage(), context); + warn("Error converting [" + node + "]: to " + type + ":\n" + Utils.printStackTrace(e), context); } return null; } @@ -255,28 +262,22 @@ public static Optional parseJsonString(Object object) { */ public static T treeToValue(JsonNode jn, Class clazz, VisitorContext context) throws JsonProcessingException { + var fixed = false; T value; try { value = CONVERT_JSON_MAPPER.treeToValue(jn, clazz); } catch (Exception e) { - // fix for problem with groovy. Jackson throw exception with ApiResponse class - if (clazz == ApiResponse.class && jn.has("content")) { - var contentNode = jn.get("content"); - ((ObjectNode) jn).set("content", null); - value = CONVERT_JSON_MAPPER.treeToValue(jn, clazz); - var result = new Content(); - if (contentNode.isArray()) { - for (var content : contentNode) { - processMediaType(result, content); - } - } else { - processMediaType(result, contentNode); - } - ((ApiResponse) value).setContent(result); + // maybe exception with groovy + if (context.getLanguage() == VisitorContext.Language.GROOVY) { + value = fixForGroovy(jn, clazz, null); + fixed = true; } else { throw e; } } + if (!fixed && context.getLanguage() == VisitorContext.Language.GROOVY) { + value = fixForGroovy(jn, clazz, null); + } if (value == null) { return null; @@ -320,6 +321,112 @@ public static T treeToValue(JsonNode jn, Class clazz, VisitorContext cont return value; } + private static Map deserMap(String name, JsonNode jn, Class clazz) throws JsonProcessingException { + + var mapNode = jn.get(name); + if (mapNode == null) { + return null; + } + ((ObjectNode) jn).remove(name); + + var iter = mapNode.fieldNames(); + var result = new HashMap(); + while (iter.hasNext()) { + var entryKey = iter.next(); + var objectNode = mapNode.get(entryKey); + var object = CONVERT_JSON_MAPPER.treeToValue(objectNode, clazz); + result.put(entryKey, object); + } + return !result.isEmpty() ? result : null; + } + + private static T fixForGroovy(JsonNode jn, Class clazz, Exception e) throws JsonProcessingException { + + // fix for problem with groovy. Jackson throw exception with Operation class with content and mediaType + // see https://github.com/micronaut-projects/micronaut-openapi/issues/1418 + if (clazz == Operation.class) { + + var requestBodyNode = jn.get("requestBody"); + ((ObjectNode) jn).remove("requestBody"); + T value = CONVERT_JSON_MAPPER.treeToValue(jn, clazz); + var requestBody = fixContentForGroovy(requestBodyNode, RequestBody.class); + ((Operation) value).setRequestBody(requestBody); + + var responsesNode = jn.get("responses"); + ((ObjectNode) jn).remove("responses"); + ApiResponses responses = null; + if (responsesNode != null && !responsesNode.isEmpty()) { + responses = new ApiResponses(); + var iter = responsesNode.fields(); + while (iter.hasNext()) { + var entry = iter.next(); + responses.put(entry.getKey(), fixContentForGroovy(entry.getValue(), ApiResponse.class)); + } + } + ((Operation) value).setResponses(responses); + return value; + } else if (clazz == ApiResponse.class + || clazz == Header.class + || clazz == Parameter.class + || clazz == RequestBody.class) { + return fixContentForGroovy(jn, clazz); + } else { + return CONVERT_JSON_MAPPER.treeToValue(jn, clazz); + } + } + + private static T fixContentForGroovy(JsonNode parentNode, Class clazz) throws JsonProcessingException { + if (parentNode == null) { + return null; + } + Map examples = null; + Map encoding = null; + Map extensions = null; + Schema schema = null; + JsonNode mediaTypeNode = null; + + var contentNode = parentNode.get("content"); + if (contentNode != null) { + examples = deserMap("examples", contentNode, Example.class); + encoding = deserMap("encoding", contentNode, Encoding.class); + extensions = deserMap("extensions", contentNode, Object.class); + var schemaNode = contentNode.get("schema"); + if (schemaNode != null) { + schema = CONVERT_JSON_MAPPER.treeToValue(schemaNode, Schema.class); + } + + mediaTypeNode = contentNode.get("mediaType"); + ((ObjectNode) contentNode).remove("mediaType"); + } + var value = CONVERT_JSON_MAPPER.treeToValue(parentNode, clazz); + Content content = null; + if (value instanceof ApiResponse apiResponse) { + content = apiResponse.getContent(); + } else if (value instanceof Header header) { + content = header.getContent(); + } else if (value instanceof Parameter parameter) { + content = parameter.getContent(); + } else if (value instanceof RequestBody requestBody) { + content = requestBody.getContent(); + } + + if (content != null) { + var mediaType = content.get("schema"); + content.remove("schema"); + if (mediaType == null) { + mediaType = new MediaType(); + } + var contentType = mediaTypeNode != null ? mediaTypeNode.textValue() : io.micronaut.http.MediaType.APPLICATION_JSON; + content.put(contentType, mediaType); + mediaType.setExamples(examples); + mediaType.setEncoding(encoding); + mediaType.setExtensions(extensions); + mediaType.setSchema(schema); + } + + return value; + } + private static void processMediaType(Content result, JsonNode content) throws JsonProcessingException { var mediaType = content.has("mediaType") ? content.get("mediaType").asText() : io.micronaut.http.MediaType.APPLICATION_JSON; var mediaTypeObj = CONVERT_JSON_MAPPER.treeToValue(content, MediaType.class); diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiControllerVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiControllerVisitor.java index f03294bec4..76eb8fc9fd 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiControllerVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiControllerVisitor.java @@ -184,7 +184,7 @@ private List mediaTypes(MethodElement element, Class mediaTypes(String... arr) { return DEFAULT_MEDIA_TYPES; } return Arrays.stream(arr) - .map(MediaType::of) + .map(Utils::getMediaType) .toList(); } diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java b/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java index bed6b4115e..b3caa9a5f1 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java @@ -91,6 +91,20 @@ public final class Utils { private Utils() { } + /** + * Get or create MediaType object by name. + * + * @param mediaTypeName name of mediaType + * @return MediaType object + */ + public static MediaType getMediaType(String mediaTypeName) { + try { + return MediaType.of(mediaTypeName); + } catch (Exception e) { + return new MediaType(mediaTypeName); + } + } + /** * @return An Instance of default {@link PropertyPlaceholderResolver} to resolve placeholders. */ diff --git a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiPojoControllerGroovySpec.groovy b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiPojoControllerGroovySpec.groovy index 6b801ab045..114b5d2153 100644 --- a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiPojoControllerGroovySpec.groovy +++ b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiPojoControllerGroovySpec.groovy @@ -1,6 +1,7 @@ package io.micronaut.openapi.visitor import io.micronaut.ast.transform.test.AbstractBeanDefinitionSpec +import io.micronaut.http.MediaType import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.media.Schema import spock.lang.Issue @@ -199,4 +200,134 @@ public class MyBean {} openAPI.paths."/test1".post.requestBody.content."application/json".schema.additionalProperties == true openAPI.paths."/test1".post.requestBody.content."application/json".schema.default == null } + + void "test operation with content groovy"() { + when: + buildBeanDefinition("test.MyBean", ''' +package test; + +import groovy.transform.CompileStatic +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Status +import io.micronaut.http.multipart.CompletedFileUpload +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Encoding +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.parameters.RequestBody +import io.swagger.v3.oas.annotations.responses.ApiResponse + +@Controller('/file') +@CompileStatic +class FileController { + + @Post(value = "/", consumes = MediaType.MULTIPART_FORM_DATA) + @Status(HttpStatus.NO_CONTENT) + @Operation( + operationId = 'UploadFile', + summary = 'Upload a file', + requestBody = @RequestBody( + description = 'File request', + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA, + encoding = [ + @Encoding( + name = "file", + contentType = MediaType.APPLICATION_OCTET_STREAM + ) + ], + examples = [ + @ExampleObject( + name = "example-1", + summary = "sum", + description = "this is description", + value = "{\\"name\\":\\"Charlie\\"}") + ], + schema = @Schema(type = 'object')) + ), + responses = [ + @ApiResponse(responseCode = '204', description = 'OK'), + ] + ) + void uploadFile(CompletedFileUpload file) { + assert file.bytes + } + +} + +@jakarta.inject.Singleton +public class MyBean {} +''') + + OpenAPI openAPI = Utils.testReference + + then: + openAPI.paths."/file".post.requestBody.content."multipart/form-data" + openAPI.paths."/file".post.requestBody.content."multipart/form-data".schema + openAPI.paths."/file".post.requestBody.content."multipart/form-data".encoding.file.contentType == MediaType.APPLICATION_OCTET_STREAM + } + + void "test requestBody groovy"() { + when: + buildBeanDefinition("test.MyBean", ''' +package test + +import groovy.transform.CompileStatic +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Status +import io.micronaut.http.multipart.CompletedFileUpload +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Encoding +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.parameters.RequestBody + +@Controller('/file') +@CompileStatic +class FileController { + + @Post(value = "/", consumes = MediaType.MULTIPART_FORM_DATA) + @Status(HttpStatus.NO_CONTENT) + void uploadFile(@RequestBody( + description = 'File request', + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA, + encoding = [ + @Encoding( + name = "file", + contentType = MediaType.APPLICATION_OCTET_STREAM + ) + ], + examples = [ + @ExampleObject( + name = "example-1", + summary = "sum", + description = "this is description", + value = "{\\"name\\":\\"Charlie\\"}") + ], + schema = @Schema(type = 'object')) + ) CompletedFileUpload file) { + assert file.bytes + } + +} + +@jakarta.inject.Singleton +public class MyBean {} +''') + + OpenAPI openAPI = Utils.testReference + + then: + openAPI.paths."/file".post.requestBody.content."multipart/form-data" + openAPI.paths."/file".post.requestBody.content."multipart/form-data".schema + openAPI.paths."/file".post.requestBody.content."multipart/form-data".encoding.file.contentType == MediaType.APPLICATION_OCTET_STREAM + } }