From 72877af2b22c74bf4c056e5095cb76b74bb44d72 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 552cef5d..0581e92d 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 81d0b12bcfd1e08b05e9cfc053a83f8f1c04713b 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 0581e92d..41c7da8b 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 997c653e306917e66f31b05039f3c144e45332a3 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 d0c891f3..dc8fb2b8 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 ba80f811f3612a59116ab299cc2532557efcbac1 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 41c7da8b..432b551a 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 dc8fb2b8..2ab48cea 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)