From 5de709151a5c5b7df4b16462dd3aaaaa18a82248 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:19:16 +0700 Subject: [PATCH] fix(openapi-yaml): localize nested multi-file component $refs When multi-file specs are resolved, swagger-parser can leave nested $refs as relative file paths into the root document (e.g. ./swagger.yml#/components/schemas/ComplexType). Rewrite those to internal #/components/... refs when the target already exists in local components so the bundled openapi.yaml is self-contained and valid. Fixes #24528 --- .../languages/OpenAPIYamlGenerator.java | 143 ++++++++++++++++++ .../codegen/yaml/YamlGeneratorTest.java | 37 +++++ .../schemas/ArrayOfComplexTypes.yml | 6 + .../3_0/issue_24528/schemas/ComplexType.yml | 12 ++ .../schemas/NullableComplexType.yml | 6 + .../3_0/issue_24528/schemas/SimpleType.yml | 4 + .../resources/3_0/issue_24528/swagger.yml | 43 ++++++ 7 files changed, 251 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ArrayOfComplexTypes.yml create mode 100644 modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ComplexType.yml create mode 100644 modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/NullableComplexType.yml create mode 100644 modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/SimpleType.yml create mode 100644 modules/openapi-generator/src/test/resources/3_0/issue_24528/swagger.yml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java index 8dc4befee8de..4b3d3752c11a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java @@ -19,16 +19,22 @@ import com.google.common.collect.ImmutableMap; import com.samskivert.mustache.Mustache.Lambda; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Schema; +import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.*; import org.openapitools.codegen.meta.features.*; import org.openapitools.codegen.serializer.SerializerUtils; import org.openapitools.codegen.templating.mustache.OnChangeLambda; +import org.openapitools.codegen.utils.ModelUtils; import org.openapitools.codegen.utils.OpenAPISorter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; @@ -98,6 +104,17 @@ public void processOpts() { } } + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + // swagger-parser can leave nested multi-file $refs as relative paths into the root + // document (e.g. ./swagger.yml#/components/schemas/ComplexType). Rewrite those to + // internal refs when the target already exists in local components so the bundled + // openapi.yaml is self-contained and valid. See #24528. + // Run in preprocessOpenAPI (before models / info processing) rather than processOpenAPI. + localizeExternalComponentRefs(openAPI); + super.preprocessOpenAPI(openAPI); + } + @Override public void processOpenAPI(OpenAPI openAPI) { if (sortOutput) { @@ -105,6 +122,132 @@ public void processOpenAPI(OpenAPI openAPI) { } } + /** + * Rewrite external-looking component $refs that still point at a local component to + * internal {@code #/components/...} form. + * + * @param openAPI OpenAPI document being processed + */ + void localizeExternalComponentRefs(OpenAPI openAPI) { + if (openAPI == null || openAPI.getComponents() == null + || openAPI.getComponents().getSchemas() == null) { + return; + } + // Walk component schemas directly so we do not call getSimpleRef (and its warnings) + // on still-external nested refs before they are rewritten. + for (Schema schema : openAPI.getComponents().getSchemas().values()) { + walkAndRewriteSchemaRefs(schema, openAPI); + } + } + + private void walkAndRewriteSchemaRefs(Schema schema, OpenAPI openAPI) { + if (schema == null) { + return; + } + rewriteSchemaRefIfLocal(schema, openAPI); + if (schema.getProperties() != null) { + for (Object property : schema.getProperties().values()) { + walkAndRewriteSchemaRefs((Schema) property, openAPI); + } + } + if (ModelUtils.isArraySchema(schema) && schema.getItems() != null) { + walkAndRewriteSchemaRefs(schema.getItems(), openAPI); + } + if (schema.getAdditionalProperties() instanceof Schema) { + walkAndRewriteSchemaRefs((Schema) schema.getAdditionalProperties(), openAPI); + } + if (schema.getAllOf() != null) { + for (Object s : schema.getAllOf()) { + walkAndRewriteSchemaRefs((Schema) s, openAPI); + } + } + if (schema.getAnyOf() != null) { + for (Object s : schema.getAnyOf()) { + walkAndRewriteSchemaRefs((Schema) s, openAPI); + } + } + if (schema.getOneOf() != null) { + for (Object s : schema.getOneOf()) { + walkAndRewriteSchemaRefs((Schema) s, openAPI); + } + } + if (schema.getNot() != null) { + walkAndRewriteSchemaRefs(schema.getNot(), openAPI); + } + } + + private void rewriteSchemaRefIfLocal(Schema schema, OpenAPI openAPI) { + if (schema == null || StringUtils.isEmpty(schema.get$ref())) { + return; + } + String localRef = toLocalComponentRef(schema.get$ref(), openAPI); + if (localRef != null) { + schema.set$ref(localRef); + } + } + + /** + * If {@code ref} is an external (file-relative or absolute) reference into a local + * component, return the internal {@code #/components/...} form; otherwise return null. + * + * @param ref original $ref value + * @param openAPI OpenAPI document used to verify the target exists locally + * @return internal ref, or null when no rewrite should be applied + */ + String toLocalComponentRef(String ref, OpenAPI openAPI) { + if (ref == null || ref.startsWith("#/")) { + return null; + } + int fragmentIndex = ref.indexOf("#/components/"); + if (fragmentIndex < 0) { + return null; + } + String fragment = ref.substring(fragmentIndex); + // fragment is "#/components/{section}/{name}[/...]" — strip "#/" before splitting + String path = fragment.startsWith("#/") ? fragment.substring(2) : fragment.substring(1); + String[] parts = path.split("/"); + // parts[0]=components, parts[1]=section, parts[2]=name + if (parts.length < 3 || !"components".equals(parts[0])) { + return null; + } + String section = parts[1]; + String name = URLDecoder.decode(parts[2], StandardCharsets.UTF_8) + .replace("~1", "/") + .replace("~0", "~"); + if (!componentExists(openAPI.getComponents(), section, name)) { + return null; + } + return fragment; + } + + private static boolean componentExists(Components components, String section, String name) { + if (components == null || StringUtils.isEmpty(name)) { + return false; + } + switch (section) { + case "schemas": + return components.getSchemas() != null && components.getSchemas().containsKey(name); + case "parameters": + return components.getParameters() != null && components.getParameters().containsKey(name); + case "responses": + return components.getResponses() != null && components.getResponses().containsKey(name); + case "requestBodies": + return components.getRequestBodies() != null && components.getRequestBodies().containsKey(name); + case "headers": + return components.getHeaders() != null && components.getHeaders().containsKey(name); + case "examples": + return components.getExamples() != null && components.getExamples().containsKey(name); + case "links": + return components.getLinks() != null && components.getLinks().containsKey(name); + case "callbacks": + return components.getCallbacks() != null && components.getCallbacks().containsKey(name); + case "securitySchemes": + return components.getSecuritySchemes() != null && components.getSecuritySchemes().containsKey(name); + default: + return false; + } + } + @Override protected ImmutableMap.Builder addMustacheLambdas() { return super.addMustacheLambdas() diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java index c863276c1b30..26410e18de6f 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java @@ -243,4 +243,41 @@ public void testSortOutput() throws Exception { Assert.assertTrue(mammalsGet != -1 && mammalsDelete != -1, "Expected HTTP methods must be present within /mammals"); Assert.assertTrue(mammalsGet < mammalsDelete, "GET must appear before DELETE within /mammals"); } + + @Test + public void testIssue24528NestedExternalRefsLocalized() throws Exception { + Map properties = new HashMap<>(); + properties.put(OpenAPIYamlGenerator.OUTPUT_NAME, "issue_24528.yaml"); + + File output = Files.createTempDirectory("issue_24528").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("openapi-yaml") + .setAdditionalProperties(properties) + .setInputSpec("src/test/resources/3_0/issue_24528/swagger.yml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path generated = Path.of(output.getAbsolutePath(), "issue_24528.yaml"); + String generatedYaml = new String(Files.readAllBytes(generated), StandardCharsets.UTF_8); + + // Nested multi-file $refs must be rewritten to internal component refs. + Assert.assertFalse(generatedYaml.contains("swagger.yml#/components/schemas/ComplexType"), + "Nested ComplexType $refs must not keep an external file path. Output was:\n" + generatedYaml); + Assert.assertTrue(generatedYaml.contains("#/components/schemas/ComplexType"), + "Nested ComplexType $refs must be localized. Output was:\n" + generatedYaml); + + OpenAPI actual = TestUtils.parseSpec(generated.toString()); + Schema arrayOfComplex = actual.getComponents().getSchemas().get("ArrayOfComplexTypes"); + Schema complexArrayItems = (Schema) arrayOfComplex.getProperties().get("complexArray"); + Assert.assertEquals(complexArrayItems.getItems().get$ref(), "#/components/schemas/ComplexType"); + + Schema nullableComplex = actual.getComponents().getSchemas().get("NullableComplexType"); + Schema complexOrNull = (Schema) nullableComplex.getProperties().get("complexOrNull"); + // nullable + anyOf is normalized to allOf + nullable + Assert.assertNotNull(complexOrNull.getAllOf()); + Assert.assertEquals(complexOrNull.getAllOf().get(0).get$ref(), "#/components/schemas/ComplexType"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ArrayOfComplexTypes.yml b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ArrayOfComplexTypes.yml new file mode 100644 index 000000000000..342bd09f4b6e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ArrayOfComplexTypes.yml @@ -0,0 +1,6 @@ +type: object +properties: + complexArray: + type: array + items: + $ref: '../swagger.yml#/components/schemas/ComplexType' diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ComplexType.yml b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ComplexType.yml new file mode 100644 index 000000000000..15be26d01643 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/ComplexType.yml @@ -0,0 +1,12 @@ +type: object +properties: + prop1: + $ref: '../swagger.yml#/components/schemas/SimpleType' + prop2: + type: array + items: + $ref: '../swagger.yml#/components/schemas/SimpleType' + prop3: + nullable: true + anyOf: + - $ref: '../swagger.yml#/components/schemas/SimpleType' diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/NullableComplexType.yml b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/NullableComplexType.yml new file mode 100644 index 000000000000..a17d67d50faf --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/NullableComplexType.yml @@ -0,0 +1,6 @@ +type: object +properties: + complexOrNull: + nullable: true + anyOf: + - $ref: '../swagger.yml#/components/schemas/ComplexType' diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/SimpleType.yml b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/SimpleType.yml new file mode 100644 index 000000000000..86a8c1a6345d --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_24528/schemas/SimpleType.yml @@ -0,0 +1,4 @@ +type: object +properties: + value: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/issue_24528/swagger.yml b/modules/openapi-generator/src/test/resources/3_0/issue_24528/swagger.yml new file mode 100644 index 000000000000..a78d6cab0447 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue_24528/swagger.yml @@ -0,0 +1,43 @@ +openapi: 3.0.4 +info: + version: "1.0" + title: Open API Ref Test +paths: + '/simple': + get: + responses: + '200': + description: Simple return + content: + application/json: + schema: + $ref: '#/components/schemas/SimpleType' + '/arr': + get: + responses: + '200': + description: description + content: + application/json: + schema: + $ref: '#/components/schemas/ArrayOfComplexTypes' + '/null': + get: + responses: + '200': + description: description + content: + application/json: + schema: + $ref: '#/components/schemas/NullableComplexType' + +components: + schemas: + ComplexType: + $ref: "./schemas/ComplexType.yml" + SimpleType: + $ref: "./schemas/SimpleType.yml" + ArrayOfComplexTypes: + $ref: "./schemas/ArrayOfComplexTypes.yml" + NullableComplexType: + $ref: "./schemas/NullableComplexType.yml"