diff --git a/bundler/bundler_ref_rewrite_test.go b/bundler/bundler_ref_rewrite_test.go index 3e50dc54..ee704588 100644 --- a/bundler/bundler_ref_rewrite_test.go +++ b/bundler/bundler_ref_rewrite_test.go @@ -122,6 +122,108 @@ 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) +} + +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 548d3f7e..c710a396 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -109,6 +109,44 @@ func processedRefFor( return processedNodes.GetOrZero(fullDefinition) } +func composedRefFor( + processedNodes *orderedmap.Map[string, *processRef], + absoluteKey string, +) (string, bool) { + if processedNodes == nil { + return "", false + } + + 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 + } + + parentKey = parentKey[:slash] + if ref, ok := composedRefFromProcessRef(processedNodes.GetOrZero(parentKey), absoluteKey[len(parentKey):]); ok { + return ref, true + } + } +} + +func composedRefFromProcessRef(pr *processRef, suffix string) (string, bool) { + if pr == nil || len(pr.location) == 0 { + return "", false + } + return "#/" + joinLocationAsJSONPointer(pr.location) + suffix, true +} + func calculateCollisionName(name, pointer, delimiter string, iteration int) string { jsonPointer := strings.Split(pointer, "#/") if len(jsonPointer) == 2 { @@ -776,7 +814,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 @@ -869,6 +907,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 } diff --git a/bundler/composer_functions_test.go b/bundler/composer_functions_test.go index c2b05e67..f90e499a 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/ExactMatch", 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))