From 723311e58fba48494dccc6bac01e1b82c8263f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Mon, 6 Jul 2026 22:22:18 +0200 Subject: [PATCH 1/3] fix: rewrite composed refs inside extensions Composed bundles could leave root-level vendor extension `$refs` pointing at external files even when the referenced component had been composed into the bundle. Such refs made the bundled document not self-contained. Allow the final composed-ref rewrite pass to also process `$ref` values under `x-*` extension fields, and add a regression test. Spotted on our side once we upgraded to the latest vacuum version. In our case, we use extensions similarly to the included regression tests which stopped being properly composed. Related: https://github.com/pb33f/libopenapi/commit/8585a059dd858ecd0d81d006b187bc7fbec495a6 --- bundler/bundler_ref_rewrite_test.go | 51 +++++++++++++++++++++++++++++ bundler/composer_functions.go | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/bundler/bundler_ref_rewrite_test.go b/bundler/bundler_ref_rewrite_test.go index 3e50dc54..405ce401 100644 --- a/bundler/bundler_ref_rewrite_test.go +++ b/bundler/bundler_ref_rewrite_test.go @@ -122,6 +122,57 @@ paths: assertNoFilePathRefs(t, bundled) } +func TestBundlerComposed_RewritesRootVendorExtensionRefsToComposedComponents(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Root Vendor Extension Ref Test + version: 1.0.0 +tags: + - name: things + x-related-schemas: + - $ref: './models.yaml#/components/schemas/Thing' +paths: + /things: + get: + tags: + - things + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: './models.yaml#/components/schemas/Thing'` + + modelsSpec := `components: + schemas: + Thing: + type: object + properties: + id: + type: string` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "models.yaml"), []byte(modelsSpec), 0644)) + + mainBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + + bundled, err := BundleBytesComposed(mainBytes, config, nil) + require.NoError(t, err) + + bundledStr := string(bundled) + assert.Contains(t, bundledStr, "x-related-schemas:") + assert.Contains(t, bundledStr, `$ref: '#/components/schemas/Thing'`) + assert.NotContains(t, bundledStr, "./models.yaml#/components/schemas/Thing") + assertNoFilePathRefs(t, bundled) +} + // TestBundlerComposedWithOrigins_TransitiveExternalRefs verifies transitive refs with origin tracking. // When schemas use non-standard paths like #/definitions/..., they get inlined rather than composed. func TestBundlerComposedWithOrigins_TransitiveExternalRefs(t *testing.T) { diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index 07755abe..ecbe7740 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -759,7 +759,7 @@ func walkAndRewriteRefs( // Track extension scope childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-") - if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode && !inExtension { + if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode { newRef := resolveRefToComposed(valueNode.Value, sourceIdx, processedNodes, rolodex) if newRef != valueNode.Value { valueNode.Value = newRef From cea59aa8464d9d47d35770591f349a554e3b3698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Tue, 14 Jul 2026 09:38:21 +0200 Subject: [PATCH 2/3] chore: address code review comment --- bundler/bundler_ref_rewrite_test.go | 51 +++++++++++++++++++++++++++++ bundler/composer_functions.go | 39 ++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/bundler/bundler_ref_rewrite_test.go b/bundler/bundler_ref_rewrite_test.go index 405ce401..ee704588 100644 --- a/bundler/bundler_ref_rewrite_test.go +++ b/bundler/bundler_ref_rewrite_test.go @@ -173,6 +173,57 @@ paths: assertNoFilePathRefs(t, bundled) } +func TestBundlerComposed_RewritesNestedVendorExtensionRefsToComposedComponents(t *testing.T) { + tmpDir := t.TempDir() + + mainSpec := `openapi: 3.1.0 +info: + title: Nested Vendor Extension Ref Test + version: 1.0.0 +tags: + - name: things + x-related-field: + $ref: './models.yaml#/components/schemas/Thing/properties/id' +paths: + /things: + get: + tags: + - things + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: './models.yaml#/components/schemas/Thing'` + + modelsSpec := `components: + schemas: + Thing: + type: object + properties: + id: + type: string` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(mainSpec), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "models.yaml"), []byte(modelsSpec), 0644)) + + mainBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml")) + require.NoError(t, err) + + config := datamodel.NewDocumentConfiguration() + config.BasePath = tmpDir + + bundled, err := BundleBytesComposed(mainBytes, config, nil) + require.NoError(t, err) + + bundledStr := string(bundled) + assert.Contains(t, bundledStr, "x-related-field:") + assert.Contains(t, bundledStr, `$ref: '#/components/schemas/Thing/properties/id'`) + assert.NotContains(t, bundledStr, "./models.yaml#/components/schemas/Thing/properties/id") + assertNoFilePathRefs(t, bundled) +} + // TestBundlerComposedWithOrigins_TransitiveExternalRefs verifies transitive refs with origin tracking. // When schemas use non-standard paths like #/definitions/..., they get inlined rather than composed. func TestBundlerComposedWithOrigins_TransitiveExternalRefs(t *testing.T) { diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index ecbe7740..ad8763de 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -109,6 +109,41 @@ func processedRefFor( return processedNodes.GetOrZero(fullDefinition) } +func composedRefFor( + processedNodes *orderedmap.Map[string, *processRef], + absoluteKey string, +) (string, bool) { + if processedNodes == nil { + return "", false + } + + longestKey := "" + var longestRef *processRef + for key, pr := range processedNodes.FromOldest() { + if pr == nil || len(pr.location) == 0 { + continue + } + if key == absoluteKey { + continue + } + if !strings.HasPrefix(absoluteKey, key) { + continue + } + suffix := strings.TrimPrefix(absoluteKey, key) + if suffix == "" || !strings.HasPrefix(suffix, "/") { + continue + } + if len(key) > len(longestKey) { + longestKey = key + longestRef = pr + } + } + if longestRef == nil { + return "", false + } + return "#/" + joinLocationAsJSONPointer(longestRef.location) + strings.TrimPrefix(absoluteKey, longestKey), true +} + func calculateCollisionName(name, pointer, delimiter string, iteration int) string { jsonPointer := strings.Split(pointer, "#/") if len(jsonPointer) == 2 { @@ -852,6 +887,10 @@ func resolveRefToComposed( // Only rewrite if the target was actually composed into the bundled output. // This prevents dangling refs when SearchIndexForReference resolves something // that never made it into processedNodes. + if composedRef, ok := composedRefFor(processedNodes, absoluteKey); ok { + return composedRef + } + if processedNodes.GetOrZero(absoluteKey) == nil { return refValue } From f82bb41b1d5865c1ef196166dfc2430ca36d7829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Fri, 17 Jul 2026 11:35:19 +0200 Subject: [PATCH 3/3] chore: increase test coverage --- bundler/composer_functions_test.go | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index 628c154a..ca08d00f 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -274,6 +274,41 @@ func TestProcessedRefFor(t *testing.T) { assert.Nil(t, processedRefFor(processedNodes, "/tmp/missing.yaml#/Thing", nil)) } +func TestComposedRefFor(t *testing.T) { + got, ok := composedRefFor(nil, "/tmp/common.yaml#/components/schemas/Thing/properties/id") + assert.False(t, ok) + assert.Empty(t, got) + + processedNodes := orderedmap.New[string, *processRef]() + processedNodes.Set("/tmp/common.yaml#/components/schemas/Nil", nil) + processedNodes.Set("/tmp/common.yaml#/components/schemas/Empty", &processRef{}) + processedNodes.Set("/tmp/common.yaml#/components/schemas/Thing", &processRef{ + location: []string{"components", "schemas", "Thing"}, + }) + processedNodes.Set("/tmp/common.yaml#/components/schemas/Thing/properties", &processRef{ + location: []string{"components", "schemas", "ThingProperties"}, + }) + processedNodes.Set("/tmp/common.yaml#/components/schemas/Thing/properties/id", &processRef{ + location: []string{"components", "schemas", "ExactMatch"}, + }) + + got, ok = composedRefFor(processedNodes, "/tmp/common.yaml#/components/schemas/Thing/properties/id/type") + require.True(t, ok) + assert.Equal(t, "#/components/schemas/ExactMatch/type", got) + + got, ok = composedRefFor(processedNodes, "/tmp/common.yaml#/components/schemas/ThingExtra") + assert.False(t, ok) + assert.Empty(t, got) + + got, ok = composedRefFor(processedNodes, "/tmp/common.yaml#/components/schemas/Thing/properties/id") + assert.True(t, ok) + assert.Equal(t, "#/components/schemas/ThingProperties/id", got) + + got, ok = composedRefFor(orderedmap.New[string, *processRef](), "/tmp/common.yaml#/components/schemas/Missing") + assert.False(t, ok) + assert.Empty(t, got) +} + func TestInlineProcessRef(t *testing.T) { assert.Nil(t, inlineProcessRef(nil))