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 584771349a..bcd2a79429 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(); } @@ -2281,7 +2281,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 9516ca7ac6..c4d20fe768 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/ConvertUtils.java @@ -59,9 +59,13 @@ import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.annotations.servers.ServerVariable; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; 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.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.security.SecurityRequirement; @@ -106,7 +110,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; } @@ -266,9 +270,11 @@ public static T treeToValue(JsonNode jn, Class clazz, VisitorContext cont processMediaType(result, contentNode); } ((ApiResponse) value).setContent(result); - } else { - throw e; + // 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 } + + value = fixForGroovy(jn, clazz, e); } if (value == null) { @@ -313,6 +319,77 @@ 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 && jn.has("requestBody")) { + + var requestBodyNode = jn.get("requestBody"); + ((ObjectNode) jn).remove("requestBody"); + T value = CONVERT_JSON_MAPPER.treeToValue(jn, clazz); + var requestBody = fixRequestBodyForGroovy(requestBodyNode); + ((Operation) value).setRequestBody(requestBody); + return value; + } else if (clazz == RequestBody.class) { + return (T) fixRequestBodyForGroovy(jn); + } else { + throw new RuntimeException(e); + } + } + + private static RequestBody fixRequestBodyForGroovy(JsonNode requestBodyNode) throws JsonProcessingException { + var contentNode = requestBodyNode.get("content"); + if (contentNode == null) { + return null; + } + + var examples = deserMap("examples", contentNode, Example.class); + var encoding = deserMap("encoding", contentNode, Encoding.class); + var extensions = deserMap("extensions", contentNode, Object.class); + var schemaNode = contentNode.get("schema"); + Schema schema = null; + if (schemaNode != null) { + schema = CONVERT_JSON_MAPPER.treeToValue(schemaNode, Schema.class); + } + + var mediaTypeNode = contentNode.get("mediaType"); + ((ObjectNode) contentNode).remove("mediaType"); + var requestBody = CONVERT_JSON_MAPPER.treeToValue(requestBodyNode, RequestBody.class); + var content = requestBody.getContent(); + var mediaType = content.get("schema"); + content.remove("schema"); + if (mediaType == null) { + mediaType = new MediaType(); + } + content.put(mediaTypeNode.textValue(), mediaType); + mediaType.setExamples(examples); + mediaType.setEncoding(encoding); + mediaType.setExtensions(extensions); + mediaType.setSchema(schema); + + return requestBody; + } + 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/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 + } }