From 4ee1425484b237e6d501ce3fc09b570af54960bc Mon Sep 17 00:00:00 2001 From: kuntal1461 Date: Thu, 21 May 2026 12:55:33 +0530 Subject: [PATCH] feat: allow @ExampleObject values to be loaded from a file (#5180) Add externalFile() field to @ExampleObject annotation so large example payloads can be stored in classpath or filesystem files instead of inline string constants. Supports classpath: and file: prefixes (bare paths default to classpath). When both externalFile and value are set, externalFile takes precedence; falls back to value if the file is not found. Authored-By: Kuntal Maity --- .../oas/annotations/media/ExampleObject.java | 18 ++++++ .../v3/core/util/AnnotationsUtils.java | 58 ++++++++++++++++++- .../v3/core/util/AnnotationsUtilsTest.java | 52 +++++++++++++++++ .../src/test/resources/testExampleFile.json | 1 + .../annotations/examples/ExamplesTest.java | 25 ++++++++ .../ExternalFileRequestBodyExample.yaml | 41 +++++++++++++ .../examples/external/user-request.json | 1 + 7 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 modules/swagger-core/src/test/resources/testExampleFile.json create mode 100644 modules/swagger-jaxrs2/src/test/resources/examples/ExternalFileRequestBodyExample.yaml create mode 100644 modules/swagger-jaxrs2/src/test/resources/examples/external/user-request.json diff --git a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/ExampleObject.java b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/ExampleObject.java index 9fa9088782..6fc74748b3 100644 --- a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/ExampleObject.java +++ b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/ExampleObject.java @@ -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}. + * + *

Supported path formats:

+ * + * + * @since 2.2.51 + * @return path to a file containing the example value + **/ + String externalFile() default ""; + } diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java index 7d758f4f10..18c8532f4c 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java @@ -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; @@ -432,6 +437,42 @@ public static Optional getExample(ExampleObject example, boolean ignore return Optional.empty(); } + private static String loadExternalFile(String path) { + 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; @@ -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())) { diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java index 7c3063ac79..cb23c69aa1 100644 --- a/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java @@ -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; @@ -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; @@ -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 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 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 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 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 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; } + }; + } + } diff --git a/modules/swagger-core/src/test/resources/testExampleFile.json b/modules/swagger-core/src/test/resources/testExampleFile.json new file mode 100644 index 0000000000..96a6f3e005 --- /dev/null +++ b/modules/swagger-core/src/test/resources/testExampleFile.json @@ -0,0 +1 @@ +{"id": 1} diff --git a/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/examples/ExamplesTest.java b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/examples/ExamplesTest.java index 423455dbdc..a97461d646 100644 --- a/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/examples/ExamplesTest.java +++ b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/examples/ExamplesTest.java @@ -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 @@ -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; } diff --git a/modules/swagger-jaxrs2/src/test/resources/examples/ExternalFileRequestBodyExample.yaml b/modules/swagger-jaxrs2/src/test/resources/examples/ExternalFileRequestBodyExample.yaml new file mode 100644 index 0000000000..94234e05b3 --- /dev/null +++ b/modules/swagger-jaxrs2/src/test/resources/examples/ExternalFileRequestBodyExample.yaml @@ -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" diff --git a/modules/swagger-jaxrs2/src/test/resources/examples/external/user-request.json b/modules/swagger-jaxrs2/src/test/resources/examples/external/user-request.json new file mode 100644 index 0000000000..1c9aa1dc0d --- /dev/null +++ b/modules/swagger-jaxrs2/src/test/resources/examples/external/user-request.json @@ -0,0 +1 @@ +{"subscriptionId": "1", "subscriptionItem": {"subscriptionItemId": "2"}}