Skip to content

Commit f35f7c0

Browse files
authored
fix: defer example parsing until end to await complete type information (#2071)
Signed-off-by: Michael Edgar <[email protected]>
1 parent 0fe0d03 commit f35f7c0

File tree

9 files changed

+313
-28
lines changed

9 files changed

+313
-28
lines changed

core/src/main/java/io/smallrye/openapi/runtime/io/media/ContentIO.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
import org.eclipse.microprofile.openapi.OASFactory;
66
import org.eclipse.microprofile.openapi.models.media.Content;
77
import org.eclipse.microprofile.openapi.models.media.MediaType;
8+
import org.eclipse.microprofile.openapi.models.media.Schema;
9+
import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType;
810
import org.jboss.jandex.AnnotationInstance;
911
import org.jboss.jandex.AnnotationValue;
1012

13+
import io.smallrye.openapi.internal.models.media.SchemaSupport;
14+
import io.smallrye.openapi.model.BaseModel;
1115
import io.smallrye.openapi.runtime.io.IOContext;
1216
import io.smallrye.openapi.runtime.io.IoLogging;
1317
import io.smallrye.openapi.runtime.io.ModelIO;
@@ -55,9 +59,10 @@ private Content read(AnnotationInstance[] annotations, Direction direction) {
5559

5660
if (contentType == null) {
5761
for (String mimeType : getDefaultMimeTypes(direction)) {
58-
content.addMediaType(mimeType, mediaTypeModel);
62+
content.addMediaType(mimeType, maybeParseExamples(mimeType, mediaTypeModel, true));
5963
}
6064
} else {
65+
maybeParseExamples(contentType, mediaTypeModel, false);
6166
content.addMediaType(contentType, mediaTypeModel);
6267
}
6368
}
@@ -78,6 +83,27 @@ private String[] getDefaultMimeTypes(Direction direction) {
7883
}
7984
}
8085

86+
private MediaType maybeParseExamples(String contentType, MediaType model, boolean copyOnWrite) {
87+
boolean parseExamples;
88+
89+
if (contentType.toUpperCase().contains("JSON")) {
90+
parseExamples = true;
91+
} else {
92+
Schema schema = model.getSchema();
93+
parseExamples = schema != null && SchemaSupport.getNonNullType(schema) != SchemaType.STRING;
94+
}
95+
96+
if (parseExamples && (model.getExample() != null || model.getExamples() != null)) {
97+
if (copyOnWrite) {
98+
model = BaseModel.deepCopy(model, MediaType.class);
99+
}
100+
101+
scannerContext().getUnparsedExamples().add(model);
102+
}
103+
104+
return model;
105+
}
106+
81107
static <T> T nonNullOrElse(T value, T defaultValue) {
82108
return value != null ? value : defaultValue;
83109
}

core/src/main/java/io/smallrye/openapi/runtime/io/media/ExampleObjectIO.java

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import io.smallrye.openapi.runtime.io.MapModelIO;
1111
import io.smallrye.openapi.runtime.io.Names;
1212
import io.smallrye.openapi.runtime.io.ReferenceIO;
13-
import io.smallrye.openapi.runtime.scanner.AnnotationScannerExtension;
1413

1514
public class ExampleObjectIO<V, A extends V, O extends V, AB, OB> extends MapModelIO<Example, V, A, O, AB, OB>
1615
implements ReferenceIO<V, A, O, AB, OB> {
@@ -31,32 +30,9 @@ public Example read(AnnotationInstance annotation) {
3130
example.setRef(ReferenceType.EXAMPLE.refValue(annotation));
3231
example.setSummary(value(annotation, PROP_SUMMARY));
3332
example.setDescription(value(annotation, PROP_DESCRIPTION));
34-
example.setValue(parseValue(value(annotation, PROP_VALUE)));
33+
example.setValue(value(annotation, PROP_VALUE));
3534
example.setExternalValue(value(annotation, PROP_EXTERNAL_VALUE));
3635
example.setExtensions(extensionIO().readExtensible(annotation));
3736
return example;
3837
}
39-
40-
/**
41-
* Reads an example value and decode it, the parsing is delegated to the extensions
42-
* currently set in the scanner. The default value will parse the string using Jackson.
43-
*
44-
* @param value the value to decode
45-
* @return a Java representation of the 'value' property, either a String or parsed value
46-
*
47-
*/
48-
public Object parseValue(String value) {
49-
Object parsedValue = value;
50-
51-
if (value != null) {
52-
for (AnnotationScannerExtension e : scannerContext().getExtensions()) {
53-
parsedValue = e.parseValue(value);
54-
if (parsedValue != null) {
55-
break;
56-
}
57-
}
58-
}
59-
60-
return parsedValue;
61-
}
6238
}

core/src/main/java/io/smallrye/openapi/runtime/io/media/MediaTypeIO.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public MediaType read(AnnotationInstance annotation) {
2525
IoLogging.logger.singleAnnotationAs("@Content", "MediaType");
2626
MediaType mediaType = OASFactory.createMediaType();
2727
mediaType.setExamples(exampleObjectIO().readMap(annotation.value(PROP_EXAMPLES)));
28-
mediaType.setExample(exampleObjectIO().parseValue(value(annotation, PROP_EXAMPLE)));
28+
mediaType.setExample(value(annotation, PROP_EXAMPLE));
2929
mediaType.setSchema(schemaIO().read(annotation.value(PROP_SCHEMA)));
3030
mediaType.setEncoding(encodingIO().readMap(annotation.value(PROP_ENCODING)));
3131
mediaType.setExtensions(extensionIO().readExtensible(annotation));

core/src/main/java/io/smallrye/openapi/runtime/io/parameters/ParameterIO.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ protected boolean setProperty(Parameter model, AnnotationValue value) {
7979
model.setExamples(exampleObjectIO().readMap(value));
8080
return true;
8181
case PROP_EXAMPLE:
82-
model.setExample(exampleObjectIO().parseValue(value.asString()));
82+
model.setExample(value.asString());
8383
return true;
8484
default:
8585
break;
@@ -96,6 +96,14 @@ public Parameter read(AnnotationInstance annotation) {
9696
Extensions.setParamRef(parameter, annotation.target());
9797
}
9898

99+
if (parameter.getExample() != null || parameter.getExamples() != null) {
100+
/*
101+
* Save the parameter for later parsing. The schema may not yet be set
102+
* so we do not know if it should be parsed.
103+
*/
104+
scannerContext().getUnparsedExamples().add(parameter);
105+
}
106+
99107
return parameter;
100108
}
101109

core/src/main/java/io/smallrye/openapi/runtime/scanner/OpenApiAnnotationScanner.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.util.Optional;
1212
import java.util.Set;
1313
import java.util.function.BiConsumer;
14+
import java.util.function.Consumer;
1415
import java.util.function.Function;
1516
import java.util.function.Predicate;
1617
import java.util.function.Supplier;
@@ -21,6 +22,10 @@
2122
import org.eclipse.microprofile.openapi.models.Components;
2223
import org.eclipse.microprofile.openapi.models.OpenAPI;
2324
import org.eclipse.microprofile.openapi.models.Paths;
25+
import org.eclipse.microprofile.openapi.models.examples.Example;
26+
import org.eclipse.microprofile.openapi.models.media.MediaType;
27+
import org.eclipse.microprofile.openapi.models.media.Schema.SchemaType;
28+
import org.eclipse.microprofile.openapi.models.parameters.Parameter;
2429
import org.eclipse.microprofile.openapi.models.tags.Tag;
2530
import org.jboss.jandex.AnnotationInstance;
2631
import org.jboss.jandex.AnnotationTarget;
@@ -32,13 +37,15 @@
3237
import io.smallrye.openapi.api.SmallRyeOASConfig;
3338
import io.smallrye.openapi.api.util.ClassLoaderUtil;
3439
import io.smallrye.openapi.api.util.MergeUtil;
40+
import io.smallrye.openapi.internal.models.media.SchemaSupport;
3541
import io.smallrye.openapi.runtime.io.Names;
3642
import io.smallrye.openapi.runtime.io.OpenAPIDefinitionIO;
3743
import io.smallrye.openapi.runtime.io.schema.SchemaConstant;
3844
import io.smallrye.openapi.runtime.io.schema.SchemaFactory;
3945
import io.smallrye.openapi.runtime.scanner.spi.AnnotationScanner;
4046
import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerContext;
4147
import io.smallrye.openapi.runtime.scanner.spi.AnnotationScannerFactory;
48+
import io.smallrye.openapi.runtime.util.ModelUtil;
4249

4350
/**
4451
* Scans a deployment (using the archive and jandex annotation index) for OpenAPI annotations.
@@ -234,6 +241,7 @@ public OpenAPI scan(Predicate<String> filter) {
234241

235242
sortTags(annotationScannerContext, openApi);
236243
sortMaps(openApi);
244+
parseExamples();
237245

238246
return openApi;
239247
}
@@ -384,4 +392,55 @@ private <P, V> void sort(P parent, Function<P, Map<String, V>> source, BiConsume
384392

385393
target.accept(parent, sorted);
386394
}
395+
396+
private void parseExamples() {
397+
for (Object model : annotationScannerContext.getUnparsedExamples()) {
398+
Map<String, Example> examples = null;
399+
400+
if (model instanceof Parameter) {
401+
Parameter param = (Parameter) model;
402+
403+
if (ModelUtil.getParameterSchemas(param).stream()
404+
.map(s -> ModelUtil.dereference(annotationScannerContext.getOpenApi(), s))
405+
.anyMatch(s -> SchemaSupport.getNonNullType(s) != SchemaType.STRING)) {
406+
parseExample(param.getExample(), param::setExample);
407+
examples = param.getExamples();
408+
}
409+
} else if (model instanceof MediaType) {
410+
MediaType mediaType = (MediaType) model;
411+
parseExample(mediaType.getExample(), mediaType::setExample);
412+
examples = mediaType.getExamples();
413+
}
414+
415+
if (examples != null) {
416+
for (Example example : examples.values()) {
417+
parseExample(example.getValue(), example::setValue);
418+
}
419+
}
420+
}
421+
}
422+
423+
/**
424+
* Reads an example value and decodes it, the parsing is delegated to the
425+
* extensions currently set in the scanner. The default value will parse the
426+
* string using Jackson.
427+
*
428+
* @param value
429+
* the value to decode
430+
* @param setter
431+
* the consumer/setter lambda where the parsed value is to be
432+
* placed when non-null
433+
*/
434+
private void parseExample(Object value, Consumer<Object> setter) {
435+
if (value instanceof String) {
436+
for (AnnotationScannerExtension e : annotationScannerContext.getExtensions()) {
437+
value = e.parseValue((String) value);
438+
439+
if (value != null) {
440+
setter.accept(value);
441+
break;
442+
}
443+
}
444+
}
445+
}
387446
}

core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScannerContext.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public class AnnotationScannerContext {
6464
private final IOContext<?, ?, ?, ?, ?> ioContext;
6565

6666
private final Map<String, MethodInfo> operationIdMap = new HashMap<>();
67+
private final List<Object> unparsedExamples = new ArrayList<>();
6768

6869
public AnnotationScannerContext(FilteredIndexView index,
6970
ClassLoader classLoader,
@@ -226,4 +227,8 @@ public Annotations annotations() {
226227
public <V, A extends V, O extends V, AB, OB> IOContext<V, A, O, AB, OB> io() { // NOSONAR - ignore wildcards in return type
227228
return (IOContext<V, A, O, AB, OB>) ioContext;
228229
}
230+
231+
public List<Object> getUnparsedExamples() {
232+
return unparsedExamples;
233+
}
229234
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.smallrye.openapi.runtime.scanner;
2+
3+
import java.io.IOException;
4+
import java.time.LocalDateTime;
5+
6+
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
7+
import org.eclipse.microprofile.openapi.annotations.media.Content;
8+
import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
9+
import org.eclipse.microprofile.openapi.annotations.media.Schema;
10+
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
11+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
12+
import org.json.JSONException;
13+
import org.junit.jupiter.api.Test;
14+
15+
class ExampleParseTests extends IndexScannerTestBase {
16+
17+
@Test
18+
void testParametersExamplesParsedWhenJson() throws IOException, JSONException {
19+
@jakarta.ws.rs.Path("examples")
20+
class ExampleResource {
21+
@Parameter(example = "2019-05-02T09:51:25.265", examples = {
22+
@ExampleObject(name = "datetime", value = "2099-12-31T23:59:59.999")
23+
})
24+
@jakarta.ws.rs.QueryParam("createDateTimeMax")
25+
public LocalDateTime createDateTimeMax;
26+
27+
@Parameter(schema = @Schema(type = SchemaType.OBJECT), example = "{ \"key\": \"value\" }", examples = {
28+
@ExampleObject(name = "json", value = "{ \"key\": \"value\" }")
29+
})
30+
@jakarta.ws.rs.QueryParam("encodedJson")
31+
public Object encodedJson;
32+
33+
@Parameter(schema = @Schema(type = SchemaType.STRING), example = "\"key\": \"value\"", examples = {
34+
@ExampleObject(name = "keyValuePair", value = "\"key\": \"value\"")
35+
})
36+
@jakarta.ws.rs.QueryParam("keyValuePair")
37+
public Object keyValuePair;
38+
39+
@Parameter(example = "3.1415")
40+
@jakarta.ws.rs.QueryParam("floatingpoint")
41+
public Float floatingpoint;
42+
43+
@jakarta.ws.rs.GET
44+
@jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
45+
public jakarta.ws.rs.core.Response getExamples() {
46+
return null;
47+
}
48+
}
49+
50+
assertJsonEquals("examples.parameters.json", ExampleResource.class);
51+
}
52+
53+
@Test
54+
void testResponseContentExampleParsedWhenJson() throws IOException, JSONException {
55+
@jakarta.ws.rs.Path("examples")
56+
class ExampleResource {
57+
final String exampleIds = "1200635948\n" +
58+
"1201860613\n" +
59+
"1201901219";
60+
61+
@jakarta.ws.rs.GET
62+
@jakarta.ws.rs.Produces({
63+
jakarta.ws.rs.core.MediaType.TEXT_PLAIN,
64+
jakarta.ws.rs.core.MediaType.APPLICATION_JSON,
65+
})
66+
@APIResponse(responseCode = "200", content = {
67+
@Content(mediaType = jakarta.ws.rs.core.MediaType.TEXT_PLAIN, example = exampleIds, examples = {
68+
@ExampleObject(name = "identifiers", value = exampleIds),
69+
}),
70+
@Content(mediaType = jakarta.ws.rs.core.MediaType.APPLICATION_JSON, example = "[ \"123\", \"456\" ]", examples = {
71+
@ExampleObject(name = "identifiers", value = "[ \"1200635948\", \"1201860613\", \"1201901219\" ]"),
72+
})
73+
})
74+
@APIResponse(responseCode = "206", description = "Partial Content", content = {
75+
@Content(example = "1", examples = {
76+
@ExampleObject(name = "integer", value = "1"),
77+
}),
78+
})
79+
public jakarta.ws.rs.core.Response getExamples() {
80+
return null;
81+
}
82+
}
83+
84+
assertJsonEquals("examples.responses.json", ExampleResource.class);
85+
}
86+
}

0 commit comments

Comments
 (0)