From 7ec67a99ba5744f0675f312507ed6e2646e6ce0f 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/4] 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 548d3f7e..d488ca45 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -776,7 +776,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 ebc4990f3ef2b0b2bf245f5e65cbeff6efbef0f2 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/4] 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 d488ca45..6a6b58f5 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 { @@ -869,6 +904,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 1ab8b9a6ad908553501389dc222eb7ab9f224e58 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/4] 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 c2b05e67..9ff9a47c 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -276,6 +276,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)) From 2e36ac22e7384e4389cfc2c62e635a7fcd6ee1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Tue, 28 Jul 2026 21:55:39 +0200 Subject: [PATCH 4/4] chore: optimize composed ref lookup --- bundler/composer_functions.go | 43 ++++++++++++++++-------------- bundler/composer_functions_test.go | 2 +- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index 6a6b58f5..c710a396 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -117,31 +117,34 @@ func composedRefFor( 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 ref, ok := composedRefFromProcessRef(processedNodes.GetOrZero(absoluteKey), ""); ok { + return ref, true + } + + fragmentStart := strings.Index(absoluteKey, "#/") + if fragmentStart == -1 { + return "", false + } + + parentKey := absoluteKey + for { + slash := strings.LastIndex(parentKey, "/") + if slash <= fragmentStart+1 { + return "", false } - if len(key) > len(longestKey) { - longestKey = key - longestRef = pr + + parentKey = parentKey[:slash] + if ref, ok := composedRefFromProcessRef(processedNodes.GetOrZero(parentKey), absoluteKey[len(parentKey):]); ok { + return ref, true } } - if longestRef == nil { +} + +func composedRefFromProcessRef(pr *processRef, suffix string) (string, bool) { + if pr == nil || len(pr.location) == 0 { return "", false } - return "#/" + joinLocationAsJSONPointer(longestRef.location) + strings.TrimPrefix(absoluteKey, longestKey), true + return "#/" + joinLocationAsJSONPointer(pr.location) + suffix, true } func calculateCollisionName(name, pointer, delimiter string, iteration int) string { diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index 9ff9a47c..f90e499a 100644 --- a/bundler/composer_functions_test.go +++ b/bundler/composer_functions_test.go @@ -304,7 +304,7 @@ func TestComposedRefFor(t *testing.T) { got, ok = composedRefFor(processedNodes, "/tmp/common.yaml#/components/schemas/Thing/properties/id") assert.True(t, ok) - assert.Equal(t, "#/components/schemas/ThingProperties/id", got) + assert.Equal(t, "#/components/schemas/ExactMatch", got) got, ok = composedRefFor(orderedmap.New[string, *processRef](), "/tmp/common.yaml#/components/schemas/Missing") assert.False(t, ok)