Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,22 @@
**/
String description() default "";

/**
* A classpath or file-system path from which to load the example value at annotation
* processing time. The loaded content is used as if specified inline via the {@link #value()} field.
* When both {@code externalFile} and {@code value} are set, {@code externalFile} takes
* precedence if the file is found; otherwise falls back to {@code value}.
*
* <p>Supported path formats:</p>
* <ul>
* <li>{@code classpath:openapi/examples/user.json} — loads from classpath (default when no prefix)</li>
* <li>{@code file:/absolute/path/to/example.json} — loads from filesystem</li>
* <li>{@code openapi/examples/user.json} — no prefix, treated as classpath</li>
* </ul>
*
* @since 2.2.51
* @return path to a file containing the example value
**/
String externalFile() default "";

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,16 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
Expand Down Expand Up @@ -432,6 +437,42 @@ public static Optional<Example> getExample(ExampleObject example, boolean ignore
return Optional.empty();
}

private static String loadExternalFile(String path) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I am not affiliated with this repo. These are my own opinions.


I believe file handling is outside of what AnnotationUtils responsibilities, and that it would make sense to extract this to some separate file-handling util class. This so that the AnnotationUtils only know "I should fetch this from a file", but it does not have to consider itself in any way of how files are actually fetched and parsed.

if (StringUtils.isBlank(path)) {
return null;
}
try {
if (path.startsWith("file:")) {
return new String(Files.readAllBytes(Paths.get(path.substring("file:".length()))), StandardCharsets.UTF_8);
} else {
String resourcePath = path.startsWith("classpath:") ? path.substring("classpath:".length()) : path;
if (resourcePath.startsWith("/")) {
resourcePath = resourcePath.substring(1);
}
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = AnnotationsUtils.class.getClassLoader();
}
try (InputStream is = cl.getResourceAsStream(resourcePath)) {
if (is == null) {
LOGGER.warn("Could not find classpath resource for @ExampleObject externalFile: {}", path);
return null;
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(chunk)) != -1) {
buffer.write(chunk, 0, bytesRead);
}
return buffer.toString(StandardCharsets.UTF_8.name());
}
}
} catch (Exception e) {
LOGGER.warn("Failed to load @ExampleObject externalFile '{}': {}", path, e.getMessage());
return null;
}
}

private static boolean resolveExample(Example exampleObject, ExampleObject example) {

boolean isEmpty = true;
Expand All @@ -449,13 +490,24 @@ private static boolean resolveExample(Example exampleObject, ExampleObject examp
isEmpty = false;
exampleObject.setExternalValue(example.externalValue());
}
if (StringUtils.isNotBlank(example.value())) {
// externalFile takes precedence over value; falls back to value if file not found
String effectiveValue = null;
if (StringUtils.isNotBlank(example.externalFile())) {
effectiveValue = loadExternalFile(example.externalFile());
if (effectiveValue != null) {
isEmpty = false;
}
}
if (effectiveValue == null && StringUtils.isNotBlank(example.value())) {
effectiveValue = example.value();
isEmpty = false;
}
if (effectiveValue != null) {
try {
ObjectMapper mapper = ObjectMapperFactory.buildStrictGenericObjectMapper();
exampleObject.setValue(mapper.readTree(example.value()));
exampleObject.setValue(mapper.readTree(effectiveValue));
} catch (IOException e) {
exampleObject.setValue(example.value());
exampleObject.setValue(effectiveValue);
}
}
if (StringUtils.isNotBlank(example.ref())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.DependentRequired;
import io.swagger.v3.oas.annotations.media.DiscriminatorMapping;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.media.Schema;
Expand All @@ -33,6 +34,7 @@
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import static org.testng.AssertJUnit.assertNull;

Expand Down Expand Up @@ -954,4 +956,54 @@ public void sentinelShouldNeverAppearInResolvedSchema() throws Exception {
"Sentinel value must never appear in resolved schema default");
}

@Test
public void testGetExampleWithExternalClasspathFile() {
ExampleObject exampleObject = buildExampleObject("testExampleFile.json", "");
Optional<io.swagger.v3.oas.models.examples.Example> result = AnnotationsUtils.getExample(exampleObject, true);
assertTrue(result.isPresent());
assertNotNull(result.get().getValue());
assertTrue(result.get().getValue() instanceof JsonNode, "File content should be parsed as JsonNode");
}

@Test
public void testGetExampleExternalFilePrecedenceOverValue() {
ExampleObject exampleObject = buildExampleObject("testExampleFile.json", "{\"override\": true}");
Optional<io.swagger.v3.oas.models.examples.Example> result = AnnotationsUtils.getExample(exampleObject, true);
assertTrue(result.isPresent());
assertFalse(result.get().getValue().toString().contains("override"),
"externalFile should take precedence over value when file is found");
}

@Test
public void testGetExampleMissingExternalFileFallsBackToValue() {
ExampleObject exampleObject = buildExampleObject("nonexistent-file.json", "{\"fallback\": true}");
Optional<io.swagger.v3.oas.models.examples.Example> result = AnnotationsUtils.getExample(exampleObject, true);
assertTrue(result.isPresent());
assertTrue(result.get().getValue().toString().contains("fallback"),
"When externalFile is missing, value should be used as fallback");
}

@Test
public void testGetExampleWithClasspathPrefix() {
ExampleObject exampleObject = buildExampleObject("classpath:testExampleFile.json", "");
Optional<io.swagger.v3.oas.models.examples.Example> result = AnnotationsUtils.getExample(exampleObject, true);
assertTrue(result.isPresent());
assertNotNull(result.get().getValue());
assertTrue(result.get().getValue() instanceof JsonNode, "classpath: prefixed file should be parsed as JsonNode");
}

private ExampleObject buildExampleObject(String externalFile, String value) {
return new ExampleObject() {
@Override public Class<? extends Annotation> annotationType() { return ExampleObject.class; }
@Override public String name() { return "test"; }
@Override public String summary() { return ""; }
@Override public String value() { return value; }
@Override public String externalValue() { return ""; }
@Override public Extension[] extensions() { return new Extension[0]; }
@Override public String ref() { return ""; }
@Override public String description() { return ""; }
@Override public String externalFile() { return externalFile; }
};
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id": 1}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public void testAnnotatedModel() {
compareToYamlFile(ExamplesTest.AnnotatedModelAndContentExample.class, "examples/");
}

@Test
public void testExternalFileExample() {
compareToYamlFile(ExamplesTest.ExternalFileRequestBodyExample.class, "examples/");
}

static class ResponseExampleSchema {
@Path("/test")
@POST
Expand Down Expand Up @@ -492,6 +497,26 @@ public ExamplesTest.SubscriptionResponse subscribe(@RequestBody(description = "C
}
}

static class ExternalFileRequestBodyExample {
@Path("/test")
@POST
public Subscription testRequestBody(
@RequestBody(
description = "Created user object",
required = true,
content = @Content(
examples = {
@ExampleObject(
name = "Default Request",
externalFile = "examples/external/user-request.json",
summary = "Subscription Example from file")
}
)
) Subscription sub) {
return null;
}
}

static class SubscriptionResponse {
public String subscriptionId;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
openapi: 3.0.1
paths:
/test:
post:
operationId: testRequestBody
requestBody:
description: Created user object
content:
'*/*':
schema:
$ref: "#/components/schemas/Subscription"
examples:
Default Request:
summary: Subscription Example from file
description: Default Request
value:
subscriptionId: "1"
subscriptionItem:
subscriptionItemId: "2"
required: true
responses:
default:
description: default response
content:
'*/*':
schema:
$ref: "#/components/schemas/Subscription"
components:
schemas:
SubscriptionItem:
type: object
properties:
subscriptionItemId:
type: string
Subscription:
type: object
properties:
subscriptionId:
type: string
subscriptionItem:
$ref: "#/components/schemas/SubscriptionItem"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"subscriptionId": "1", "subscriptionItem": {"subscriptionItemId": "2"}}