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 @@ -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;
Expand Down Expand Up @@ -98,13 +104,150 @@ 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) {
OpenAPISorter.sort(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()) {

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.

P1: External component refs outside components.schemas are never visited, so the generated single-file document can still contain the exact invalid-looking refs this change is intended to remove. The walker is started only for schema components; it does not inspect schemas in path operations or in component parameters, responses, request bodies, headers, callbacks, or path items. The existing InlineModelResolver has dedicated traversal for those containers, which confirms they can carry schema refs. The localization pass should traverse all schema-bearing locations in the OpenAPI document, not just the top-level schema map.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java, line 138:

<comment>External component refs outside `components.schemas` are never visited, so the generated single-file document can still contain the exact invalid-looking refs this change is intended to remove. The walker is started only for schema components; it does not inspect schemas in path operations or in component parameters, responses, request bodies, headers, callbacks, or path items. The existing `InlineModelResolver` has dedicated traversal for those containers, which confirms they can carry schema refs. The localization pass should traverse all schema-bearing locations in the OpenAPI document, not just the top-level schema map.</comment>

<file context>
@@ -98,13 +104,150 @@ public void processOpts() {
+        }
+        // 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);
+        }
</file context>

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) {

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.

P2: Refs nested in several schema keywords are still omitted from localization. The recursive walker handles properties, items, additionalProperties, allOf/anyOf/oneOf, and not, but not patternProperties, dependentSchemas, prefixItems, if/then/else, contains, or the other JSON Schema sub-schema fields supported by this codebase. A multi-file OpenAPI 3.1 schema using one of those fields can therefore still serialize an external-looking component ref.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java, line 174:

<comment>Refs nested in several schema keywords are still omitted from localization. The recursive walker handles properties, items, additionalProperties, allOf/anyOf/oneOf, and not, but not `patternProperties`, `dependentSchemas`, `prefixItems`, `if`/`then`/`else`, `contains`, or the other JSON Schema sub-schema fields supported by this codebase. A multi-file OpenAPI 3.1 schema using one of those fields can therefore still serialize an external-looking component ref.</comment>

<file context>
@@ -98,13 +104,150 @@ public void processOpts() {
+                walkAndRewriteSchemaRefs((Schema<?>) s, openAPI);
+            }
+        }
+        if (schema.getNot() != null) {
+            walkAndRewriteSchemaRefs(schema.getNot(), openAPI);
+        }
</file context>

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);

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.

P1: A reference to a genuinely external document can be silently rebound to a same-named root component. This code keeps only the #/components/... suffix and then checks whether that name exists locally; it never verifies that the URI prefix identifies the input/root document. For example, common.yml#/components/schemas/User will become #/components/schemas/User whenever the root also defines User, changing the generated schema's meaning. The rewrite should be limited to refs proven to resolve to the root document, rather than to every external URI with a matching component name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java, line 205:

<comment>A reference to a genuinely external document can be silently rebound to a same-named root component. This code keeps only the `#/components/...` suffix and then checks whether that name exists locally; it never verifies that the URI prefix identifies the input/root document. For example, `common.yml#/components/schemas/User` will become `#/components/schemas/User` whenever the root also defines `User`, changing the generated schema's meaning. The rewrite should be limited to refs proven to resolve to the root document, rather than to every external URI with a matching component name.</comment>

<file context>
@@ -98,13 +104,150 @@ public void processOpts() {
+        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);
</file context>

// 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)

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.

P2: Component names containing a literal + are not localized. URLDecoder.decode turns + into a space, so ./swagger.yml#/components/schemas/A+B is looked up as A B; when the actual local key is A+B, componentExists returns false and the external ref remains in the generated YAML. Fragment decoding should preserve literal plus characters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/OpenAPIYamlGenerator.java, line 214:

<comment>Component names containing a literal `+` are not localized. `URLDecoder.decode` turns `+` into a space, so `./swagger.yml#/components/schemas/A+B` is looked up as `A B`; when the actual local key is `A+B`, `componentExists` returns false and the external ref remains in the generated YAML. Fragment decoding should preserve literal plus characters.</comment>

<file context>
@@ -98,13 +104,150 @@ public void processOpts() {
+            return null;
+        }
+        String section = parts[1];
+        String name = URLDecoder.decode(parts[2], StandardCharsets.UTF_8)
+                .replace("~1", "/")
+                .replace("~0", "~");
</file context>
Suggested change
String name = URLDecoder.decode(parts[2], StandardCharsets.UTF_8)
String name = URLDecoder.decode(parts[2].replace("+", "%2B"), 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<String, Lambda> addMustacheLambdas() {
return super.addMustacheLambdas()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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"),

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.

P3: The regression test can pass while other nested refs remain external. The ComplexType.yml fixture contains refs to SimpleType, but this assertion only rejects the ComplexType suffix; an implementation that rewrites only the ComplexType refs would satisfy both this assertion and the later ComplexType checks while leaving all SimpleType refs unchanged. Broadening the assertion to reject any swagger.yml#/components/ ref (or explicitly asserting the three ComplexType properties) would cover the full fixture.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/test/java/org/openapitools/codegen/yaml/YamlGeneratorTest.java, line 267:

<comment>The regression test can pass while other nested refs remain external. The `ComplexType.yml` fixture contains refs to `SimpleType`, but this assertion only rejects the `ComplexType` suffix; an implementation that rewrites only the `ComplexType` refs would satisfy both this assertion and the later `ComplexType` checks while leaving all `SimpleType` refs unchanged. Broadening the assertion to reject any `swagger.yml#/components/` ref (or explicitly asserting the three `ComplexType` properties) would cover the full fixture.</comment>

<file context>
@@ -243,4 +243,41 @@ public void testSortOutput() throws Exception {
+        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"),
</file context>
Suggested change
Assert.assertFalse(generatedYaml.contains("swagger.yml#/components/schemas/ComplexType"),
Assert.assertFalse(generatedYaml.contains("swagger.yml#/components/"),

"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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type: object
properties:
complexArray:
type: array
items:
$ref: '../swagger.yml#/components/schemas/ComplexType'
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type: object
properties:
complexOrNull:
nullable: true
anyOf:
- $ref: '../swagger.yml#/components/schemas/ComplexType'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: object
properties:
value:
type: string
Original file line number Diff line number Diff line change
@@ -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"
Loading