From 39f99e7eaf1bd980e9992c71596d2709b8e58abd Mon Sep 17 00:00:00 2001 From: Michael Edgar Date: Thu, 14 Nov 2024 06:04:41 -0500 Subject: [PATCH] fix: defer example parsing until end to await complete type information Signed-off-by: Michael Edgar --- .../openapi/runtime/io/media/ContentIO.java | 28 +++++- .../runtime/io/media/ExampleObjectIO.java | 26 +----- .../openapi/runtime/io/media/MediaTypeIO.java | 2 +- .../runtime/io/parameters/ParameterIO.java | 10 ++- .../scanner/OpenApiAnnotationScanner.java | 59 +++++++++++++ .../scanner/spi/AnnotationScannerContext.java | 5 ++ .../runtime/scanner/ExampleParseTests.java | 86 +++++++++++++++++++ .../runtime/scanner/examples.parameters.json | 72 ++++++++++++++++ .../runtime/scanner/examples.responses.json | 53 ++++++++++++ 9 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ExampleParseTests.java create mode 100644 extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.parameters.json create mode 100644 extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.responses.json diff --git a/core/src/main/java/io/smallrye/openapi/runtime/io/media/ContentIO.java b/core/src/main/java/io/smallrye/openapi/runtime/io/media/ContentIO.java index e84013b87..2f8af36bd 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/io/media/ContentIO.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/io/media/ContentIO.java @@ -5,9 +5,13 @@ import org.eclipse.microprofile.openapi.OASFactory; import org.eclipse.microprofile.openapi.models.media.Content; import org.eclipse.microprofile.openapi.models.media.MediaType; +import org.eclipse.microprofile.openapi.models.media.Schema; +import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; +import io.smallrye.openapi.internal.models.media.SchemaSupport; +import io.smallrye.openapi.model.BaseModel; import io.smallrye.openapi.runtime.io.IOContext; import io.smallrye.openapi.runtime.io.IoLogging; import io.smallrye.openapi.runtime.io.ModelIO; @@ -55,9 +59,10 @@ private Content read(AnnotationInstance[] annotations, Direction direction) { if (contentType == null) { for (String mimeType : getDefaultMimeTypes(direction)) { - content.addMediaType(mimeType, mediaTypeModel); + content.addMediaType(mimeType, maybeParseExamples(mimeType, mediaTypeModel, true)); } } else { + maybeParseExamples(contentType, mediaTypeModel, false); content.addMediaType(contentType, mediaTypeModel); } } @@ -78,6 +83,27 @@ private String[] getDefaultMimeTypes(Direction direction) { } } + private MediaType maybeParseExamples(String contentType, MediaType model, boolean copyOnWrite) { + boolean parseExamples; + + if (contentType.toUpperCase().contains("JSON")) { + parseExamples = true; + } else { + Schema schema = model.getSchema(); + parseExamples = schema != null && SchemaSupport.getNonNullType(schema) != SchemaType.STRING; + } + + if (parseExamples && (model.getExample() != null || model.getExamples() != null)) { + if (copyOnWrite) { + model = BaseModel.deepCopy(model, MediaType.class); + } + + scannerContext().getUnparsedExamples().add(model); + } + + return model; + } + static T nonNullOrElse(T value, T defaultValue) { return value != null ? value : defaultValue; } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/io/media/ExampleObjectIO.java b/core/src/main/java/io/smallrye/openapi/runtime/io/media/ExampleObjectIO.java index 7b68a152a..9437cb950 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/io/media/ExampleObjectIO.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/io/media/ExampleObjectIO.java @@ -10,7 +10,6 @@ import io.smallrye.openapi.runtime.io.MapModelIO; import io.smallrye.openapi.runtime.io.Names; import io.smallrye.openapi.runtime.io.ReferenceIO; -import io.smallrye.openapi.runtime.scanner.AnnotationScannerExtension; public class ExampleObjectIO extends MapModelIO implements ReferenceIO { @@ -31,32 +30,9 @@ public Example read(AnnotationInstance annotation) { example.setRef(ReferenceType.EXAMPLE.refValue(annotation)); example.setSummary(value(annotation, PROP_SUMMARY)); example.setDescription(value(annotation, PROP_DESCRIPTION)); - example.setValue(parseValue(value(annotation, PROP_VALUE))); + example.setValue(value(annotation, PROP_VALUE)); example.setExternalValue(value(annotation, PROP_EXTERNAL_VALUE)); example.setExtensions(extensionIO().readExtensible(annotation)); return example; } - - /** - * Reads an example value and decode it, the parsing is delegated to the extensions - * currently set in the scanner. The default value will parse the string using Jackson. - * - * @param value the value to decode - * @return a Java representation of the 'value' property, either a String or parsed value - * - */ - public Object parseValue(String value) { - Object parsedValue = value; - - if (value != null) { - for (AnnotationScannerExtension e : scannerContext().getExtensions()) { - parsedValue = e.parseValue(value); - if (parsedValue != null) { - break; - } - } - } - - return parsedValue; - } } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/io/media/MediaTypeIO.java b/core/src/main/java/io/smallrye/openapi/runtime/io/media/MediaTypeIO.java index 386716114..efd84b33a 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/io/media/MediaTypeIO.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/io/media/MediaTypeIO.java @@ -25,7 +25,7 @@ public MediaType read(AnnotationInstance annotation) { IoLogging.logger.singleAnnotationAs("@Content", "MediaType"); MediaType mediaType = OASFactory.createMediaType(); mediaType.setExamples(exampleObjectIO().readMap(annotation.value(PROP_EXAMPLES))); - mediaType.setExample(exampleObjectIO().parseValue(value(annotation, PROP_EXAMPLE))); + mediaType.setExample(value(annotation, PROP_EXAMPLE)); mediaType.setSchema(schemaIO().read(annotation.value(PROP_SCHEMA))); mediaType.setEncoding(encodingIO().readMap(annotation.value(PROP_ENCODING))); mediaType.setExtensions(extensionIO().readExtensible(annotation)); diff --git a/core/src/main/java/io/smallrye/openapi/runtime/io/parameters/ParameterIO.java b/core/src/main/java/io/smallrye/openapi/runtime/io/parameters/ParameterIO.java index 25a931616..6efa806d6 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/io/parameters/ParameterIO.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/io/parameters/ParameterIO.java @@ -79,7 +79,7 @@ protected boolean setProperty(Parameter model, AnnotationValue value) { model.setExamples(exampleObjectIO().readMap(value)); return true; case PROP_EXAMPLE: - model.setExample(exampleObjectIO().parseValue(value.asString())); + model.setExample(value.asString()); return true; default: break; @@ -96,6 +96,14 @@ public Parameter read(AnnotationInstance annotation) { Extensions.setParamRef(parameter, annotation.target()); } + if (parameter.getExample() != null || parameter.getExamples() != null) { + /* + * Save the parameter for later parsing. The schema may not yet be set + * so we do not know if it should be parsed. + */ + scannerContext().getUnparsedExamples().add(parameter); + } + return parameter; } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiAnnotationScanner.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiAnnotationScanner.java index 4b9c0de6c..664e4097a 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiAnnotationScanner.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiAnnotationScanner.java @@ -11,6 +11,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -21,6 +22,10 @@ import org.eclipse.microprofile.openapi.models.Components; import org.eclipse.microprofile.openapi.models.OpenAPI; import org.eclipse.microprofile.openapi.models.Paths; +import org.eclipse.microprofile.openapi.models.examples.Example; +import org.eclipse.microprofile.openapi.models.media.MediaType; +import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType; +import org.eclipse.microprofile.openapi.models.parameters.Parameter; import org.eclipse.microprofile.openapi.models.tags.Tag; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; @@ -32,6 +37,7 @@ import io.smallrye.openapi.api.SmallRyeOASConfig; import io.smallrye.openapi.api.util.ClassLoaderUtil; import io.smallrye.openapi.api.util.MergeUtil; +import io.smallrye.openapi.internal.models.media.SchemaSupport; import io.smallrye.openapi.runtime.io.Names; import io.smallrye.openapi.runtime.io.OpenAPIDefinitionIO; import io.smallrye.openapi.runtime.io.schema.SchemaConstant; @@ -39,6 +45,7 @@ import io.smallrye.openapi.runtime.scanner.spi.AnnotationScanner; import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerContext; import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerFactory; +import io.smallrye.openapi.runtime.util.ModelUtil; /** * Scans a deployment (using the archive and jandex annotation index) for OpenAPI annotations. @@ -234,6 +241,7 @@ public OpenAPI scan(Predicate filter) { sortTags(annotationScannerContext, openApi); sortMaps(openApi); + parseExamples(); return openApi; } @@ -384,4 +392,55 @@ private void sort(P parent, Function> source, BiConsume target.accept(parent, sorted); } + + private void parseExamples() { + for (Object model : annotationScannerContext.getUnparsedExamples()) { + Map examples = null; + + if (model instanceof Parameter) { + Parameter param = (Parameter) model; + + if (ModelUtil.getParameterSchemas(param).stream() + .map(s -> ModelUtil.dereference(annotationScannerContext.getOpenApi(), s)) + .anyMatch(s -> SchemaSupport.getNonNullType(s) != SchemaType.STRING)) { + parseExample(param.getExample(), param::setExample); + examples = param.getExamples(); + } + } else if (model instanceof MediaType) { + MediaType mediaType = (MediaType) model; + parseExample(mediaType.getExample(), mediaType::setExample); + examples = mediaType.getExamples(); + } + + if (examples != null) { + for (Example example : examples.values()) { + parseExample(example.getValue(), example::setValue); + } + } + } + } + + /** + * Reads an example value and decodes it, the parsing is delegated to the + * extensions currently set in the scanner. The default value will parse the + * string using Jackson. + * + * @param value + * the value to decode + * @param setter + * the consumer/setter lambda where the parsed value is to be + * placed when non-null + */ + private void parseExample(Object value, Consumer setter) { + if (value instanceof String) { + for (AnnotationScannerExtension e : annotationScannerContext.getExtensions()) { + value = e.parseValue((String) value); + + if (value != null) { + setter.accept(value); + break; + } + } + } + } } diff --git a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScannerContext.java b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScannerContext.java index d6a2e6ab8..6a6e7e172 100644 --- a/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScannerContext.java +++ b/core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScannerContext.java @@ -64,6 +64,7 @@ public class AnnotationScannerContext { private final IOContext ioContext; private final Map operationIdMap = new HashMap<>(); + private final List unparsedExamples = new ArrayList<>(); public AnnotationScannerContext(FilteredIndexView index, ClassLoader classLoader, @@ -226,4 +227,8 @@ public Annotations annotations() { public IOContext io() { // NOSONAR - ignore wildcards in return type return (IOContext) ioContext; } + + public List getUnparsedExamples() { + return unparsedExamples; + } } diff --git a/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ExampleParseTests.java b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ExampleParseTests.java new file mode 100644 index 000000000..62e9df4b0 --- /dev/null +++ b/extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ExampleParseTests.java @@ -0,0 +1,86 @@ +package io.smallrye.openapi.runtime.scanner; + +import java.io.IOException; +import java.time.LocalDateTime; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.ExampleObject; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class ExampleParseTests extends IndexScannerTestBase { + + @Test + void testParametersExamplesParsedWhenJson() throws IOException, JSONException { + @jakarta.ws.rs.Path("examples") + class ExampleResource { + @Parameter(example = "2019-05-02T09:51:25.265", examples = { + @ExampleObject(name = "datetime", value = "2099-12-31T23:59:59.999") + }) + @jakarta.ws.rs.QueryParam("createDateTimeMax") + public LocalDateTime createDateTimeMax; + + @Parameter(schema = @Schema(type = SchemaType.OBJECT), example = "{ \"key\": \"value\" }", examples = { + @ExampleObject(name = "json", value = "{ \"key\": \"value\" }") + }) + @jakarta.ws.rs.QueryParam("encodedJson") + public Object encodedJson; + + @Parameter(schema = @Schema(type = SchemaType.STRING), example = "\"key\": \"value\"", examples = { + @ExampleObject(name = "keyValuePair", value = "\"key\": \"value\"") + }) + @jakarta.ws.rs.QueryParam("keyValuePair") + public Object keyValuePair; + + @Parameter(example = "3.1415") + @jakarta.ws.rs.QueryParam("floatingpoint") + public Float floatingpoint; + + @jakarta.ws.rs.GET + @jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) + public jakarta.ws.rs.core.Response getExamples() { + return null; + } + } + + assertJsonEquals("examples.parameters.json", ExampleResource.class); + } + + @Test + void testResponseContentExampleParsedWhenJson() throws IOException, JSONException { + @jakarta.ws.rs.Path("examples") + class ExampleResource { + final String exampleIds = "1200635948\n" + + "1201860613\n" + + "1201901219"; + + @jakarta.ws.rs.GET + @jakarta.ws.rs.Produces({ + jakarta.ws.rs.core.MediaType.TEXT_PLAIN, + jakarta.ws.rs.core.MediaType.APPLICATION_JSON, + }) + @APIResponse(responseCode = "200", content = { + @Content(mediaType = jakarta.ws.rs.core.MediaType.TEXT_PLAIN, example = exampleIds, examples = { + @ExampleObject(name = "identifiers", value = exampleIds), + }), + @Content(mediaType = jakarta.ws.rs.core.MediaType.APPLICATION_JSON, example = "[ \"123\", \"456\" ]", examples = { + @ExampleObject(name = "identifiers", value = "[ \"1200635948\", \"1201860613\", \"1201901219\" ]"), + }) + }) + @APIResponse(responseCode = "206", description = "Partial Content", content = { + @Content(example = "1", examples = { + @ExampleObject(name = "integer", value = "1"), + }), + }) + public jakarta.ws.rs.core.Response getExamples() { + return null; + } + } + + assertJsonEquals("examples.responses.json", ExampleResource.class); + } +} diff --git a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.parameters.json b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.parameters.json new file mode 100644 index 000000000..7a9feaa5b --- /dev/null +++ b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.parameters.json @@ -0,0 +1,72 @@ +{ + "openapi" : "3.1.0", + "components" : { + "schemas" : { + "LocalDateTime" : { + "type" : "string", + "format" : "date-time", + "examples" : [ "2022-03-10T12:15:50" ] + } + } + }, + "paths" : { + "/examples" : { + "parameters" : [ { + "example" : "2019-05-02T09:51:25.265", + "examples" : { + "datetime" : { + "value" : "2099-12-31T23:59:59.999" + } + }, + "name" : "createDateTimeMax", + "in" : "query", + "schema" : { + "$ref" : "#/components/schemas/LocalDateTime" + } + }, { + "example" : { + "key" : "value" + }, + "examples" : { + "json" : { + "value" : { + "key" : "value" + } + } + }, + "schema" : { + "type" : "object" + }, + "name" : "encodedJson", + "in" : "query" + }, { + "example" : 3.1415, + "name" : "floatingpoint", + "in" : "query", + "schema" : { + "type" : "number", + "format" : "float" + } + }, { + "example" : "\"key\": \"value\"", + "examples" : { + "keyValuePair" : { + "value" : "\"key\": \"value\"" + } + }, + "schema" : { + "type" : "string" + }, + "name" : "keyValuePair", + "in" : "query" + } ], + "get" : { + "responses" : { + "200" : { + "description" : "OK" + } + } + } + } + } +} diff --git a/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.responses.json b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.responses.json new file mode 100644 index 000000000..c4dc19d06 --- /dev/null +++ b/extension-jaxrs/src/test/resources/io/smallrye/openapi/runtime/scanner/examples.responses.json @@ -0,0 +1,53 @@ +{ + "openapi" : "3.1.0", + "paths" : { + "/examples" : { + "get" : { + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "text/plain" : { + "examples" : { + "identifiers" : { + "value" : "1200635948\n1201860613\n1201901219" + } + }, + "example" : "1200635948\n1201860613\n1201901219" + }, + "application/json" : { + "examples" : { + "identifiers" : { + "value" : [ "1200635948", "1201860613", "1201901219" ] + } + }, + "example" : [ "123", "456" ] + } + } + }, + "206" : { + "description" : "Partial Content", + "content" : { + "text/plain" : { + "examples" : { + "integer" : { + "value" : "1" + } + }, + "example" : "1" + }, + "application/json" : { + "examples" : { + "integer" : { + "value" : 1 + } + }, + "example" : 1 + } + } + } + } + } + } + } +}