Skip to content

Commit 8585a05

Browse files
committed
fix: rebase vendor extension $refs in composed bundles
Rewrite $ref values nested under x-* extension keys so they are relative to the bundled root document instead of their original source file, and skip extension refs during composer remapping so they are not lifted into components or inlined.
1 parent 335e473 commit 8585a05

7 files changed

Lines changed: 373 additions & 0 deletions

bundler/bundler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@ func composeWithOrigins(model *v3.Document, compositionConfig *BundleComposition
538538
if err := handleDiscriminatorMappingIndexes(cf, rootIndex, rolodex); err != nil {
539539
return nil, err
540540
}
541+
rewriteExtensionRefsForComposedBundle(rolodex)
541542

542543
processedNodes := orderedmap.New[string, *processRef]()
543544
var errs []error
@@ -701,6 +702,7 @@ func compose(model *v3.Document, compositionConfig *BundleCompositionConfig) ([]
701702
if err := handleDiscriminatorMappingIndexes(cf, rootIndex, rolodex); err != nil {
702703
return nil, err
703704
}
705+
rewriteExtensionRefsForComposedBundle(rolodex)
704706

705707
processedNodes := orderedmap.New[string, *processRef]()
706708
var errs []error

bundler/bundler_composer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ func handleIndex(c *handleIndexConfig) error {
6262
var indexesToExplore []*index.SpecIndex
6363

6464
for _, sequenced := range sequencedReferences {
65+
if sequenced.IsExtensionRef {
66+
continue
67+
}
6568
mappedReference := mappedReferences[sequenced.FullDefinition]
6669

6770
// Check for invalid sibling properties if strict validation is enabled

bundler/bundler_ref_rewrite_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,85 @@ properties:
11281128
"Extension internal refs should be preserved as-is")
11291129
}
11301130

1131+
func TestBundlerComposed_RewritesExcludedVendorExtensionDollarRefsFromExternalPathItem(t *testing.T) {
1132+
tmpDir := t.TempDir()
1133+
rootBytes := writeComposedExtensionRefFixture(t, tmpDir)
1134+
1135+
config := datamodel.NewDocumentConfiguration()
1136+
config.BasePath = tmpDir
1137+
config.AllowFileReferences = true
1138+
config.ExcludeExtensionRefs = true
1139+
1140+
bundled, err := BundleBytesComposed(rootBytes, config, nil)
1141+
require.NoError(t, err)
1142+
1143+
bundledStr := string(bundled)
1144+
assert.Contains(t, bundledStr, "code_samples/C_sharp/echo/post.cs")
1145+
assert.NotContains(t, bundledStr, "../code_samples/C_sharp/echo/post.cs")
1146+
1147+
doc, err := libopenapi.NewDocumentWithConfiguration(bundled, &datamodel.DocumentConfiguration{
1148+
BasePath: tmpDir,
1149+
AllowFileReferences: true,
1150+
ExcludeExtensionRefs: true,
1151+
})
1152+
require.NoError(t, err)
1153+
v3Doc, buildErr := doc.BuildV3Model()
1154+
require.NoError(t, buildErr)
1155+
require.NotNil(t, v3Doc)
1156+
}
1157+
1158+
func TestBundlerComposed_RewritesIncludedVendorExtensionDollarRefsWithoutComposingThem(t *testing.T) {
1159+
tmpDir := t.TempDir()
1160+
rootBytes := writeComposedExtensionRefFixture(t, tmpDir)
1161+
1162+
config := datamodel.NewDocumentConfiguration()
1163+
config.BasePath = tmpDir
1164+
config.AllowFileReferences = true
1165+
1166+
bundled, err := BundleBytesComposed(rootBytes, config, nil)
1167+
require.NoError(t, err)
1168+
1169+
bundledStr := string(bundled)
1170+
assert.Contains(t, bundledStr, "code_samples/C_sharp/echo/post.cs")
1171+
assert.NotContains(t, bundledStr, "../code_samples/C_sharp/echo/post.cs")
1172+
assert.NotContains(t, bundledStr, "Console.WriteLine",
1173+
"Extension $refs should be rebased, not composed or inlined")
1174+
}
1175+
1176+
func writeComposedExtensionRefFixture(t *testing.T, tmpDir string) []byte {
1177+
t.Helper()
1178+
1179+
rootSpec := `openapi: 3.1.0
1180+
info:
1181+
title: Test API
1182+
version: 1.0.0
1183+
paths:
1184+
/echo:
1185+
$ref: ./paths/echo.yaml
1186+
`
1187+
1188+
echoPath := `post:
1189+
summary: Echo
1190+
responses:
1191+
'200':
1192+
description: OK
1193+
x-codeSamples:
1194+
- lang: C#
1195+
source:
1196+
$ref: ../code_samples/C_sharp/echo/post.cs
1197+
`
1198+
1199+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "openapi.yaml"), []byte(rootSpec), 0644))
1200+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "paths"), 0755))
1201+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "paths", "echo.yaml"), []byte(echoPath), 0644))
1202+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "code_samples", "C_sharp", "echo"), 0755))
1203+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "code_samples", "C_sharp", "echo", "post.cs"), []byte(`Console.WriteLine("hello");`), 0644))
1204+
1205+
rootBytes, err := os.ReadFile(filepath.Join(tmpDir, "openapi.yaml"))
1206+
require.NoError(t, err)
1207+
return rootBytes
1208+
}
1209+
11311210
// TestBundlerComposed_HandlesEmptyMappingValues verifies that empty discriminator
11321211
// mapping values don't cause errors
11331212
func TestBundlerComposed_HandlesEmptyMappingValues(t *testing.T) {

bundler/composer_functions.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ func remapIndex(idx *index.SpecIndex, processedNodes *orderedmap.Map[string, *pr
215215
rewiredRefNodes := make(map[*yaml.Node]struct{}, len(seq))
216216

217217
for _, sequenced := range seq {
218+
if sequenced.IsExtensionRef {
219+
continue
220+
}
218221
if refValNode := utils.GetRefValueNode(sequenced.Node); refValNode != nil {
219222
rewiredRefNodes[refValNode] = struct{}{}
220223
}
@@ -224,6 +227,9 @@ func remapIndex(idx *index.SpecIndex, processedNodes *orderedmap.Map[string, *pr
224227
mapped := idx.GetMappedReferences()
225228

226229
for _, mRef := range mapped {
230+
if mRef.IsExtensionRef {
231+
continue
232+
}
227233
if refValNode := utils.GetRefValueNode(mRef.Node); refValNode != nil {
228234
if _, ok := rewiredRefNodes[refValNode]; ok {
229235
continue

bundler/composer_functions_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/pb33f/libopenapi/orderedmap"
1414
"github.com/stretchr/testify/assert"
1515
"github.com/stretchr/testify/require"
16+
"go.yaml.in/yaml/v4"
1617
)
1718

1819
func TestHandleFileImport_StripsFragment(t *testing.T) {
@@ -36,6 +37,30 @@ func TestWalkAndRewriteRefs_NilNode(t *testing.T) {
3637
})
3738
}
3839

40+
func TestRemapIndexSkipsMappedExtensionRefs(t *testing.T) {
41+
var root yaml.Node
42+
require.NoError(t, yaml.Unmarshal([]byte(`openapi: 3.1.0`), &root))
43+
idx := index.NewSpecIndexWithConfig(&root, index.CreateOpenAPIIndexConfig())
44+
45+
refNode := &yaml.Node{
46+
Kind: yaml.MappingNode,
47+
Content: []*yaml.Node{
48+
{Kind: yaml.ScalarNode, Value: "$ref"},
49+
{Kind: yaml.ScalarNode, Value: "./sample.md"},
50+
},
51+
}
52+
idx.SetMappedReferences(map[string]*index.Reference{
53+
"./sample.md": {
54+
FullDefinition: "./sample.md",
55+
Node: refNode,
56+
IsExtensionRef: true,
57+
},
58+
})
59+
60+
remapIndex(idx, orderedmap.New[string, *processRef]())
61+
assert.Equal(t, "./sample.md", refNode.Content[1].Value)
62+
}
63+
3964
func TestResolveRefToComposed_PreservesExternalRefs(t *testing.T) {
4065
assert.Equal(t, "https://example.com/schema.json", resolveRefToComposed("https://example.com/schema.json", nil, nil, nil))
4166
assert.Equal(t, "urn:example:thing", resolveRefToComposed("urn:example:thing", nil, nil, nil))

bundler/extension_refs.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ package bundler
66

77
import (
88
"context"
9+
"os"
10+
"path/filepath"
11+
"strings"
912

13+
"github.com/pb33f/libopenapi/utils"
1014
"go.yaml.in/yaml/v4"
1115

1216
"github.com/pb33f/libopenapi/index"
@@ -123,3 +127,118 @@ func replaceRefNodeWithContent(refNode, content *yaml.Node) {
123127
refNode.LineComment = content.LineComment
124128
refNode.FootComment = content.FootComment
125129
}
130+
131+
// rewriteExtensionRefsForComposedBundle rebases $ref values found under x-* extension
132+
// keys from their original source file location to the bundled root document.
133+
func rewriteExtensionRefsForComposedBundle(rolodex *index.Rolodex) {
134+
if rolodex == nil {
135+
return
136+
}
137+
rootIdx := rolodex.GetRootIndex()
138+
if rootIdx == nil {
139+
return
140+
}
141+
rewriteExtensionRefsForComposedIndex(rootIdx, rootIdx)
142+
for _, idx := range rolodex.GetIndexes() {
143+
rewriteExtensionRefsForComposedIndex(idx, rootIdx)
144+
}
145+
}
146+
147+
func rewriteExtensionRefsForComposedIndex(sourceIdx, rootIdx *index.SpecIndex) {
148+
if sourceIdx == nil || rootIdx == nil {
149+
return
150+
}
151+
if sourceIdx.GetSpecAbsolutePath() == rootIdx.GetSpecAbsolutePath() {
152+
return
153+
}
154+
walkAndRewriteComposedExtensionRefs(sourceIdx.GetRootNode(), sourceIdx, rootIdx, false)
155+
}
156+
157+
func walkAndRewriteComposedExtensionRefs(node *yaml.Node, sourceIdx, rootIdx *index.SpecIndex, inExtension bool) {
158+
if node == nil {
159+
return
160+
}
161+
switch node.Kind {
162+
case yaml.DocumentNode:
163+
for _, child := range node.Content {
164+
walkAndRewriteComposedExtensionRefs(child, sourceIdx, rootIdx, inExtension)
165+
}
166+
case yaml.SequenceNode:
167+
for _, child := range node.Content {
168+
walkAndRewriteComposedExtensionRefs(child, sourceIdx, rootIdx, inExtension)
169+
}
170+
case yaml.MappingNode:
171+
for i := 0; i+1 < len(node.Content); i += 2 {
172+
keyNode := node.Content[i]
173+
valueNode := node.Content[i+1]
174+
if keyNode == nil {
175+
continue
176+
}
177+
childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-")
178+
if childInExtension && keyNode.Value == "$ref" && valueNode != nil && valueNode.Kind == yaml.ScalarNode {
179+
valueNode.Value = rebaseExtensionRefForComposed(valueNode.Value, sourceIdx, rootIdx)
180+
continue
181+
}
182+
walkAndRewriteComposedExtensionRefs(valueNode, sourceIdx, rootIdx, childInExtension)
183+
}
184+
}
185+
}
186+
187+
func rebaseExtensionRefForComposed(refValue string, sourceIdx, rootIdx *index.SpecIndex) string {
188+
pathPart, fragment := splitRefPathAndFragment(refValue)
189+
if pathPart == "" {
190+
if fragment == "" || sourceIdx == nil || sourceIdx.GetSpecAbsolutePath() == "" || isExternalRefURI(sourceIdx.GetSpecAbsolutePath()) {
191+
return refValue
192+
}
193+
pathPart = sourceIdx.GetSpecAbsolutePath()
194+
}
195+
if isExternalRefURI(pathPart) {
196+
return refValue
197+
}
198+
sourceDir := specDir(sourceIdx)
199+
rootDir := specDir(rootIdx)
200+
if sourceDir == "" || rootDir == "" {
201+
return refValue
202+
}
203+
targetPath := pathPart
204+
if !filepath.IsAbs(targetPath) {
205+
targetPath = utils.CheckPathOverlap(sourceDir, targetPath, string(os.PathSeparator))
206+
}
207+
absTarget, err := filepath.Abs(targetPath)
208+
if err != nil {
209+
return refValue
210+
}
211+
relTarget, err := filepath.Rel(rootDir, absTarget)
212+
if err != nil {
213+
return filepath.ToSlash(absTarget) + fragment
214+
}
215+
return filepath.ToSlash(relTarget) + fragment
216+
}
217+
218+
func splitRefPathAndFragment(refValue string) (string, string) {
219+
pathPart, fragment, found := strings.Cut(refValue, "#")
220+
if found {
221+
return pathPart, "#" + fragment
222+
}
223+
return refValue, ""
224+
}
225+
226+
func isExternalRefURI(refPath string) bool {
227+
return strings.HasPrefix(refPath, "http://") ||
228+
strings.HasPrefix(refPath, "https://") ||
229+
strings.HasPrefix(refPath, "urn:")
230+
}
231+
232+
func specDir(idx *index.SpecIndex) string {
233+
if idx == nil {
234+
return ""
235+
}
236+
specPath := idx.GetSpecAbsolutePath()
237+
if specPath == "" || isExternalRefURI(specPath) {
238+
return ""
239+
}
240+
if filepath.Ext(specPath) == "" {
241+
return specPath
242+
}
243+
return filepath.Dir(specPath)
244+
}

0 commit comments

Comments
 (0)