diff --git a/datamodel/document_config.go b/datamodel/document_config.go index a0fe469f..5da94ef3 100644 --- a/datamodel/document_config.go +++ b/datamodel/document_config.go @@ -209,6 +209,10 @@ type DocumentConfiguration struct { // - OverwriteWithRemote: Referenced properties overwrite local properties // - RejectConflicts: Throw error when properties conflict PropertyMergeStrategy PropertyMergeStrategy + + // ResolveNestedRefsWithDocumentContext uses the referenced document's path/index as the base for nested refs. + // This controls how nested relative references are interpreted during reference resolution. + ResolveNestedRefsWithDocumentContext bool } func NewDocumentConfiguration() *DocumentConfiguration { diff --git a/datamodel/low/v2/swagger.go b/datamodel/low/v2/swagger.go index 835836ad..6d079cb9 100644 --- a/datamodel/low/v2/swagger.go +++ b/datamodel/low/v2/swagger.go @@ -146,6 +146,7 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur idxConfig.IgnorePolymorphicCircularReferences = config.IgnorePolymorphicCircularReferences idxConfig.AllowUnknownExtensionContentDetection = config.AllowUnknownExtensionContentDetection idxConfig.SkipExternalRefResolution = config.SkipExternalRefResolution + idxConfig.ResolveNestedRefsWithDocumentContext = config.ResolveNestedRefsWithDocumentContext idxConfig.AvoidCircularReferenceCheck = true idxConfig.BaseURL = config.BaseURL idxConfig.BasePath = config.BasePath diff --git a/datamodel/low/v2/swagger_test.go b/datamodel/low/v2/swagger_test.go index 0fc3279d..89ae0384 100644 --- a/datamodel/low/v2/swagger_test.go +++ b/datamodel/low/v2/swagger_test.go @@ -433,3 +433,24 @@ func TestRolodexRemoteFileSystem_FailRemoteFS(t *testing.T) { assert.NotNil(t, lDoc) assert.Error(t, err) } + +func TestCreateDocumentFromConfig_ResolveNestedRefsWithDocumentContext(t *testing.T) { + spec := []byte(`swagger: "2.0" +info: + title: test + version: "1.0.0" +paths: {} +`) + info, err := datamodel.ExtractSpecInfo(spec) + assert.NoError(t, err) + + cfg := datamodel.NewDocumentConfiguration() + cfg.ResolveNestedRefsWithDocumentContext = true + + doc, err := CreateDocumentFromConfig(info, cfg) + assert.NoError(t, err) + assert.NotNil(t, doc) + assert.NotNil(t, doc.Index) + assert.NotNil(t, doc.Index.GetConfig()) + assert.True(t, doc.Index.GetConfig().ResolveNestedRefsWithDocumentContext) +} diff --git a/datamodel/low/v3/create_document.go b/datamodel/low/v3/create_document.go index 953252c9..2a51fe72 100644 --- a/datamodel/low/v3/create_document.go +++ b/datamodel/low/v3/create_document.go @@ -50,6 +50,7 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur idxConfig.AllowUnknownExtensionContentDetection = config.AllowUnknownExtensionContentDetection idxConfig.TransformSiblingRefs = config.TransformSiblingRefs idxConfig.SkipExternalRefResolution = config.SkipExternalRefResolution + idxConfig.ResolveNestedRefsWithDocumentContext = config.ResolveNestedRefsWithDocumentContext idxConfig.AvoidCircularReferenceCheck = true // handle $self field for OpenAPI 3.2+ documents diff --git a/datamodel/low/v3/create_document_test.go b/datamodel/low/v3/create_document_test.go index 9d2d1d8f..e03689a4 100644 --- a/datamodel/low/v3/create_document_test.go +++ b/datamodel/low/v3/create_document_test.go @@ -1083,3 +1083,24 @@ paths: {}` // but the index should use the configured BaseURL, not $self assert.NotNil(t, doc.Index) } + +func TestCreateDocumentFromConfig_ResolveNestedRefsWithDocumentContext(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: test + version: 1.0.0 +paths: {} +`) + info, err := datamodel.ExtractSpecInfo(spec) + assert.NoError(t, err) + + cfg := datamodel.NewDocumentConfiguration() + cfg.ResolveNestedRefsWithDocumentContext = true + + doc, err := CreateDocumentFromConfig(info, cfg) + assert.NoError(t, err) + assert.NotNil(t, doc) + assert.NotNil(t, doc.Index) + assert.NotNil(t, doc.Index.GetConfig()) + assert.True(t, doc.Index.GetConfig().ResolveNestedRefsWithDocumentContext) +} diff --git a/index/index_model.go b/index/index_model.go index ca6e0137..c346f5ad 100644 --- a/index/index_model.go +++ b/index/index_model.go @@ -273,6 +273,7 @@ func (s *SpecIndexConfig) ToDocumentConfiguration() *datamodel.DocumentConfigura AllowUnknownExtensionContentDetection: s.AllowUnknownExtensionContentDetection, TransformSiblingRefs: s.TransformSiblingRefs, MergeReferencedProperties: s.MergeReferencedProperties, + ResolveNestedRefsWithDocumentContext: s.ResolveNestedRefsWithDocumentContext, PropertyMergeStrategy: strategy, SkipExternalRefResolution: s.SkipExternalRefResolution, Logger: s.Logger, diff --git a/index/index_model_test.go b/index/index_model_test.go index f8c0a794..47becbce 100644 --- a/index/index_model_test.go +++ b/index/index_model_test.go @@ -89,6 +89,7 @@ func TestSpecIndexConfig_ToDocumentConfiguration_AllFields(t *testing.T) { UseSchemaQuickHash: true, AllowUnknownExtensionContentDetection: true, TransformSiblingRefs: true, + ResolveNestedRefsWithDocumentContext: true, } result := config.ToDocumentConfiguration() @@ -105,6 +106,7 @@ func TestSpecIndexConfig_ToDocumentConfiguration_AllFields(t *testing.T) { assert.True(t, result.UseSchemaQuickHash) assert.True(t, result.AllowUnknownExtensionContentDetection) assert.True(t, result.TransformSiblingRefs) + assert.True(t, result.ResolveNestedRefsWithDocumentContext) assert.False(t, result.MergeReferencedProperties) // default disabled for index configs } @@ -133,27 +135,27 @@ func TestSpecIndex_Release(t *testing.T) { rolodex.rootNode = &yaml.Node{Value: "rolodex-root"} idx := &SpecIndex{ - config: cfg, - root: rootNode, - pathsNode: &yaml.Node{}, - tagsNode: &yaml.Node{}, - schemasNode: &yaml.Node{}, - allRefs: map[string]*Reference{"ref": {}}, - rawSequencedRefs: []*Reference{{}}, - allMappedRefs: map[string]*Reference{"mapped": {}}, - allMappedRefsSequenced: []*ReferenceMapped{{}}, - nodeMap: map[int]map[int]*yaml.Node{1: {1: &yaml.Node{}}}, - allDescriptions: []*DescriptionReference{{}}, - allEnums: []*EnumReference{{}}, - circularReferences: []*CircularReferenceResult{{}}, - refErrors: []error{nil}, - resolver: resolver, - rolodex: rolodex, - allComponentSchemas: map[string]*Reference{"schema": {}}, - allExternalDocuments: map[string]*Reference{"ext": {}}, - externalSpecIndex: map[string]*SpecIndex{"ext": {}}, - schemaIdRegistry: map[string]*SchemaIdEntry{"id": {}}, - uri: []string{"test"}, + config: cfg, + root: rootNode, + pathsNode: &yaml.Node{}, + tagsNode: &yaml.Node{}, + schemasNode: &yaml.Node{}, + allRefs: map[string]*Reference{"ref": {}}, + rawSequencedRefs: []*Reference{{}}, + allMappedRefs: map[string]*Reference{"mapped": {}}, + allMappedRefsSequenced: []*ReferenceMapped{{}}, + nodeMap: map[int]map[int]*yaml.Node{1: {1: &yaml.Node{}}}, + allDescriptions: []*DescriptionReference{{}}, + allEnums: []*EnumReference{{}}, + circularReferences: []*CircularReferenceResult{{}}, + refErrors: []error{nil}, + resolver: resolver, + rolodex: rolodex, + allComponentSchemas: map[string]*Reference{"schema": {}}, + allExternalDocuments: map[string]*Reference{"ext": {}}, + externalSpecIndex: map[string]*SpecIndex{"ext": {}}, + schemaIdRegistry: map[string]*SchemaIdEntry{"id": {}}, + uri: []string{"test"}, } idx.Release() diff --git a/index/resolve_reference_value.go b/index/resolve_reference_value.go new file mode 100644 index 00000000..b913560f --- /dev/null +++ b/index/resolve_reference_value.go @@ -0,0 +1,95 @@ +package index + +import ( + "net/url" + "strconv" + "strings" +) + +// ResolveReferenceValue resolves a reference string to a decoded value. +// +// Resolution order: +// 1. Resolve using SpecIndex when available. +// 2. Fallback to local JSON pointer resolution (e.g. "#/components/schemas/Foo") +// using getDocData when provided. +// +// Returns nil when the reference cannot be resolved. +func ResolveReferenceValue(ref string, specIndex *SpecIndex, getDocData func() map[string]interface{}) interface{} { + if ref == "" { + return nil + } + + if specIndex != nil { + if resolvedRef, _ := specIndex.SearchIndexForReference(ref); resolvedRef != nil && resolvedRef.Node != nil { + var decoded interface{} + if err := resolvedRef.Node.Decode(&decoded); err == nil { + return decoded + } + } + } + + // Fallback parser only supports local JSON pointers ("#" root or "#/..."). + if ref != "#" && !strings.HasPrefix(ref, "#/") { + return nil + } + + if getDocData == nil { + return nil + } + docData := getDocData() + if docData == nil { + return nil + } + + return resolveLocalJSONPointer(docData, ref) +} + +func resolveLocalJSONPointer(docData map[string]interface{}, ref string) interface{} { + if ref == "" { + return nil + } + if ref == "#" { + return docData + } + if !strings.HasPrefix(ref, "#/") { + return nil + } + + segments := strings.Split(ref[2:], "/") + var current interface{} = docData + + for _, rawSegment := range segments { + segment := decodeJSONPointerToken(rawSegment) + switch node := current.(type) { + case map[string]interface{}: + next, ok := node[segment] + if !ok { + return nil + } + current = next + case []interface{}: + idx, err := strconv.Atoi(segment) + if err != nil || idx < 0 || idx >= len(node) { + return nil + } + current = node[idx] + default: + return nil + } + } + + return current +} + +func decodeJSONPointerToken(token string) string { + if strings.Contains(token, "%") { + decoded, err := url.PathUnescape(token) + if err == nil { + token = decoded + } + } + if !strings.Contains(token, "~") { + return token + } + return strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") +} diff --git a/index/resolve_reference_value_test.go b/index/resolve_reference_value_test.go new file mode 100644 index 00000000..b931da26 --- /dev/null +++ b/index/resolve_reference_value_test.go @@ -0,0 +1,158 @@ +package index + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" +) + +func TestResolveReferenceValue_FromIndex(t *testing.T) { + spec := []byte(`openapi: 3.0.0 +components: + schemas: + Label: + type: string +`) + + var root yaml.Node + err := yaml.Unmarshal(spec, &root) + assert.NoError(t, err) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveReferenceValue("#/components/schemas/Label", specIndex, nil) + asMap, ok := resolved.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "string", asMap["type"]) +} + +func TestResolveReferenceValue_DoesNotLoadDocumentDataForNonLocalRefs(t *testing.T) { + loadCount := 0 + resolved := ResolveReferenceValue("https://example.com/openapi.yaml#/components/schemas/Foo", nil, + func() map[string]interface{} { + loadCount++ + return nil + }) + assert.Nil(t, resolved) + assert.Equal(t, 0, loadCount) +} + +func TestResolveReferenceValue_DoesNotLoadDocumentDataForUnsupportedLocalAnchorRefs(t *testing.T) { + loadCount := 0 + resolved := ResolveReferenceValue("#anchor-name", nil, func() map[string]interface{} { + loadCount++ + return nil + }) + assert.Nil(t, resolved) + assert.Equal(t, 0, loadCount) +} + +func TestResolveReferenceValue_LoadsDocumentDataWhenIndexMissing(t *testing.T) { + loadCount := 0 + resolved := ResolveReferenceValue("#/components/responses/BadRequest", nil, func() map[string]interface{} { + loadCount++ + return map[string]interface{}{ + "components": map[string]interface{}{ + "responses": map[string]interface{}{ + "BadRequest": map[string]interface{}{ + "description": "bad request", + }, + }, + }, + } + }) + + assert.Equal(t, 1, loadCount) + asMap, ok := resolved.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "bad request", asMap["description"]) +} + +func TestResolveReferenceValue_LocalPointerFallbackRootPointer(t *testing.T) { + resolved := ResolveReferenceValue("#", nil, func() map[string]interface{} { + return map[string]interface{}{ + "openapi": "3.1.0", + } + }) + asMap, ok := resolved.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "3.1.0", asMap["openapi"]) +} + +func TestResolveReferenceValue_LocalPointerFallbackHandlesEscapesAndArrays(t *testing.T) { + resolved := ResolveReferenceValue("#/a~1b/c~0d/1/name", nil, func() map[string]interface{} { + return map[string]interface{}{ + "a/b": map[string]interface{}{ + "c~d": []interface{}{ + "zero", + map[string]interface{}{"name": "ok"}, + }, + }, + } + }) + assert.Equal(t, "ok", resolved) +} + +func TestResolveReferenceValue_LocalPointerFallbackHandlesURLEncodedSegments(t *testing.T) { + resolved := ResolveReferenceValue("#/paths/~1v1~1pets~1%7Bid%7D/get", nil, func() map[string]interface{} { + return map[string]interface{}{ + "paths": map[string]interface{}{ + "/v1/pets/{id}": map[string]interface{}{ + "get": map[string]interface{}{ + "operationId": "getPet", + }, + }, + }, + } + }) + asMap, ok := resolved.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "getPet", asMap["operationId"]) +} + +func TestResolveReferenceValue_LocalPointerFallbackRequiresDocProvider(t *testing.T) { + assert.Nil(t, ResolveReferenceValue("#/components/schemas/Foo", nil, nil)) +} + +func TestResolveReferenceValue_LocalPointerFallbackRequiresDocData(t *testing.T) { + assert.Nil(t, ResolveReferenceValue("#/components/schemas/Foo", nil, func() map[string]interface{} { + return nil + })) +} + +func TestResolveReferenceValue_EmptyRefReturnsNil(t *testing.T) { + assert.Nil(t, ResolveReferenceValue("", nil, nil)) +} + +func TestResolveLocalJSONPointer_InvalidInputs(t *testing.T) { + assert.Nil(t, resolveLocalJSONPointer(nil, "")) + assert.Nil(t, resolveLocalJSONPointer(map[string]interface{}{}, "components/schemas/Foo")) +} + +func TestResolveLocalJSONPointer_RootPointerReturnsDocument(t *testing.T) { + doc := map[string]interface{}{"a": "b"} + assert.Equal(t, doc, resolveLocalJSONPointer(doc, "#")) +} + +func TestResolveLocalJSONPointer_MissingMapKeyReturnsNil(t *testing.T) { + doc := map[string]interface{}{ + "components": map[string]interface{}{}, + } + assert.Nil(t, resolveLocalJSONPointer(doc, "#/components/schemas/Foo")) +} + +func TestResolveLocalJSONPointer_InvalidArrayIndexesReturnNil(t *testing.T) { + doc := map[string]interface{}{ + "items": []interface{}{"a"}, + } + assert.Nil(t, resolveLocalJSONPointer(doc, "#/items/not-an-int")) + assert.Nil(t, resolveLocalJSONPointer(doc, "#/items/2")) + assert.Nil(t, resolveLocalJSONPointer(doc, "#/items/-1")) +} + +func TestResolveLocalJSONPointer_UnsupportedIntermediateTypeReturnsNil(t *testing.T) { + doc := map[string]interface{}{ + "a": "scalar", + } + assert.Nil(t, resolveLocalJSONPointer(doc, "#/a/b")) +} diff --git a/index/resolve_refs_node.go b/index/resolve_refs_node.go new file mode 100644 index 00000000..eb072416 --- /dev/null +++ b/index/resolve_refs_node.go @@ -0,0 +1,158 @@ +package index + +import ( + "strings" + + "go.yaml.in/yaml/v4" +) + +// ResolveRefsInNode resolves local $ref values in a YAML node using the provided +// index. If a mapping contains sibling keys alongside $ref, sibling keys are +// preserved and merged into the resolved mapping (sibling values take precedence). +func ResolveRefsInNode(node *yaml.Node, idx *SpecIndex) *yaml.Node { + if node == nil || idx == nil { + return node + } + return resolveRefsInNode(node, idx, map[string]struct{}{}) +} + +func resolveRefsInNode(node *yaml.Node, idx *SpecIndex, seen map[string]struct{}) *yaml.Node { + if node == nil || idx == nil { + return node + } + + switch node.Kind { + case yaml.MappingNode: + return resolveRefsInMappingNode(node, idx, seen) + case yaml.SequenceNode: + clone := *node + clone.Content = make([]*yaml.Node, 0, len(node.Content)) + for _, item := range node.Content { + clone.Content = append(clone.Content, resolveRefsInNode(item, idx, seen)) + } + return &clone + default: + return node + } +} + +func resolveRefsInMappingNode(node *yaml.Node, idx *SpecIndex, seen map[string]struct{}) *yaml.Node { + ref, hasRef := findRefInMappingNode(node) + if !hasRef { + return cloneMappingNodeWithResolvedChildren(node, idx, seen) + } + + // This helper is intentionally local-only; keep external refs intact. + if !strings.HasPrefix(ref, "#/") { + return cloneMappingNodeWithResolvedChildren(node, idx, seen) + } + + if _, exists := seen[ref]; exists { + return cloneMappingNodeWithResolvedChildren(node, idx, seen) + } + + seen[ref] = struct{}{} + var resolved *yaml.Node + if resolvedRef, _ := idx.SearchIndexForReference(ref); resolvedRef != nil && resolvedRef.Node != nil { + resolved = resolveRefsInNode(resolvedRef.Node, idx, seen) + } + delete(seen, ref) + + if resolved == nil { + return cloneMappingNodeWithResolvedChildren(node, idx, seen) + } + + if !hasNonRefSiblings(node) { + return resolved + } + + siblings := extractResolvedSiblingPairs(node, idx, seen) + if resolved.Kind == yaml.MappingNode { + return mergeResolvedMappingWithSiblings(resolved, siblings) + } + if resolved.Kind == yaml.DocumentNode && len(resolved.Content) > 0 && resolved.Content[0] != nil && resolved.Content[0].Kind == yaml.MappingNode { + docClone := *resolved + docClone.Content = append([]*yaml.Node(nil), resolved.Content...) + docClone.Content[0] = mergeResolvedMappingWithSiblings(resolved.Content[0], siblings) + return &docClone + } + + // Fallback: keep original mapping (with $ref) but still resolve sibling values. + return cloneMappingNodeWithResolvedChildren(node, idx, seen) +} + +func hasNonRefSiblings(node *yaml.Node) bool { + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + if key != nil && key.Value != "$ref" { + return true + } + } + return false +} + +func findRefInMappingNode(node *yaml.Node) (string, bool) { + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + val := node.Content[i+1] + if key != nil && key.Value == "$ref" && val != nil && val.Kind == yaml.ScalarNode { + return val.Value, true + } + } + return "", false +} + +func extractResolvedSiblingPairs(node *yaml.Node, idx *SpecIndex, seen map[string]struct{}) []*yaml.Node { + out := make([]*yaml.Node, 0, len(node.Content)) + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + val := node.Content[i+1] + if key != nil && key.Value == "$ref" { + continue + } + out = append(out, key, resolveRefsInNode(val, idx, seen)) + } + return out +} + +func cloneMappingNodeWithResolvedChildren(node *yaml.Node, idx *SpecIndex, seen map[string]struct{}) *yaml.Node { + clone := *node + clone.Content = make([]*yaml.Node, 0, len(node.Content)) + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + val := node.Content[i+1] + clone.Content = append(clone.Content, key, resolveRefsInNode(val, idx, seen)) + } + return &clone +} + +func mergeResolvedMappingWithSiblings(resolved *yaml.Node, siblings []*yaml.Node) *yaml.Node { + merged := *resolved + merged.Content = make([]*yaml.Node, 0, len(resolved.Content)+len(siblings)) + + keyPos := make(map[string]int, len(resolved.Content)/2+len(siblings)/2) + for i := 0; i+1 < len(resolved.Content); i += 2 { + key := resolved.Content[i] + val := resolved.Content[i+1] + merged.Content = append(merged.Content, key, val) + if key != nil { + keyPos[key.Value] = len(merged.Content) - 2 + } + } + + for i := 0; i+1 < len(siblings); i += 2 { + key := siblings[i] + val := siblings[i+1] + if key == nil { + continue + } + if pos, ok := keyPos[key.Value]; ok { + merged.Content[pos+1] = val + continue + } + merged.Content = append(merged.Content, key, val) + keyPos[key.Value] = len(merged.Content) - 2 + } + + return &merged +} diff --git a/index/resolve_refs_node_test.go b/index/resolve_refs_node_test.go new file mode 100644 index 00000000..5d9bed05 --- /dev/null +++ b/index/resolve_refs_node_test.go @@ -0,0 +1,369 @@ +package index + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" +) + +func TestResolveRefsInNode_DuplicateSiblingRefsAreResolved(t *testing.T) { + spec := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + Shared: + type: object + properties: + id: + type: string +root: + first: + $ref: "#/components/schemas/Shared" + second: + $ref: "#/components/schemas/Shared" +` + + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + target := findMappingValue(root.Content[0], "root") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + + first, ok := decoded["first"].(map[string]interface{}) + assert.True(t, ok) + second, ok := decoded["second"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "object", first["type"]) + assert.Equal(t, "object", second["type"]) + assert.NotContains(t, first, "$ref") + assert.NotContains(t, second, "$ref") +} + +func TestResolveRefsInNode_PreservesSiblingKeys(t *testing.T) { + spec := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + Shared: + type: object + properties: + id: + type: string +root: + schema: + $ref: "#/components/schemas/Shared" + description: keep sibling +` + + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + rootMap := findMappingValue(root.Content[0], "root") + assert.NotNil(t, rootMap) + target := findMappingValue(rootMap, "schema") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "object", decoded["type"]) + assert.Equal(t, "keep sibling", decoded["description"]) + assert.NotContains(t, decoded, "$ref") +} + +func TestResolveRefsInNode_NilInputReturnsNil(t *testing.T) { + assert.Nil(t, ResolveRefsInNode(nil, nil)) +} + +func TestResolveRefsInNode_NilIndexReturnsOriginalNode(t *testing.T) { + node := &yaml.Node{Kind: yaml.MappingNode} + assert.Same(t, node, ResolveRefsInNode(node, nil)) +} + +func TestResolveRefsInNode_SequenceItemsAreResolved(t *testing.T) { + spec := `openapi: 3.0.0 +components: + schemas: + Shared: + type: object +root: + items: + - $ref: "#/components/schemas/Shared" + - $ref: "#/components/schemas/Shared" +` + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + rootMap := findMappingValue(root.Content[0], "root") + assert.NotNil(t, rootMap) + target := findMappingValue(rootMap, "items") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded []map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Len(t, decoded, 2) + assert.Equal(t, "object", decoded[0]["type"]) + assert.Equal(t, "object", decoded[1]["type"]) +} + +func TestResolveRefsInNode_NonLocalRefIsPreserved(t *testing.T) { + spec := `openapi: 3.0.0 +components: + schemas: + Shared: + type: object +root: + schema: + $ref: "https://example.com/openapi.yaml#/components/schemas/Remote" + nested: + $ref: "#/components/schemas/Shared" +` + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + rootMap := findMappingValue(root.Content[0], "root") + assert.NotNil(t, rootMap) + target := findMappingValue(rootMap, "schema") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "https://example.com/openapi.yaml#/components/schemas/Remote", decoded["$ref"]) + + nested, ok := decoded["nested"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "object", nested["type"]) +} + +func TestResolveRefsInNode_UnresolvedLocalRefFallsBackToOriginalMapping(t *testing.T) { + spec := `openapi: 3.0.0 +components: + schemas: + Shared: + type: object +root: + schema: + $ref: "#/components/schemas/Missing" + nested: + $ref: "#/components/schemas/Shared" +` + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + rootMap := findMappingValue(root.Content[0], "root") + assert.NotNil(t, rootMap) + target := findMappingValue(rootMap, "schema") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "#/components/schemas/Missing", decoded["$ref"]) + + nested, ok := decoded["nested"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "object", nested["type"]) +} + +func TestResolveRefsInNode_CircularRefFallsBack(t *testing.T) { + spec := `openapi: 3.0.0 +components: + schemas: + Loop: + $ref: "#/components/schemas/Loop" +root: + schema: + $ref: "#/components/schemas/Loop" + description: keep sibling +` + var root yaml.Node + err := yaml.Unmarshal([]byte(spec), &root) + assert.NoError(t, err) + + rootMap := findMappingValue(root.Content[0], "root") + assert.NotNil(t, rootMap) + target := findMappingValue(rootMap, "schema") + assert.NotNil(t, target) + + specIndex := NewSpecIndexWithConfig(&root, CreateOpenAPIIndexConfig()) + resolved := ResolveRefsInNode(target, specIndex) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "#/components/schemas/Loop", decoded["$ref"]) + assert.Equal(t, "keep sibling", decoded["description"]) +} + +func TestResolveRefsInNode_DocumentNodeMergeBranch(t *testing.T) { + var mappedDoc yaml.Node + err := yaml.Unmarshal([]byte(`type: object +properties: + id: + type: string +`), &mappedDoc) + assert.NoError(t, err) + + var targetDoc yaml.Node + err = yaml.Unmarshal([]byte(`schema: + $ref: "#/components/schemas/DocRef" + description: merged +`), &targetDoc) + assert.NoError(t, err) + target := findMappingValue(targetDoc.Content[0], "schema") + assert.NotNil(t, target) + + cfg := CreateClosedAPIIndexConfig() + idx := NewSpecIndexWithConfig(&mappedDoc, cfg) + idx.SetMappedReferences(map[string]*Reference{ + "#/components/schemas/DocRef": { + FullDefinition: "#/components/schemas/DocRef", + Node: &mappedDoc, + Index: idx, + }, + }) + + resolved := ResolveRefsInNode(target, idx) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "object", decoded["type"]) + assert.Equal(t, "merged", decoded["description"]) +} + +func TestResolveRefsInNode_ScalarResolvedRefFallsBackToOriginalMapping(t *testing.T) { + var root yaml.Node + err := yaml.Unmarshal([]byte("openapi: 3.0.0"), &root) + assert.NoError(t, err) + + cfg := CreateClosedAPIIndexConfig() + idx := NewSpecIndexWithConfig(&root, cfg) + idx.SetMappedReferences(map[string]*Reference{ + "#/components/schemas/ScalarRef": { + FullDefinition: "#/components/schemas/ScalarRef", + Node: &yaml.Node{Kind: yaml.ScalarNode, Value: "value"}, + Index: idx, + }, + }) + + var targetDoc yaml.Node + err = yaml.Unmarshal([]byte(`schema: + $ref: "#/components/schemas/ScalarRef" + description: keep +`), &targetDoc) + assert.NoError(t, err) + target := findMappingValue(targetDoc.Content[0], "schema") + assert.NotNil(t, target) + + resolved := ResolveRefsInNode(target, idx) + assert.NotNil(t, resolved) + + var decoded map[string]interface{} + err = resolved.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "#/components/schemas/ScalarRef", decoded["$ref"]) + assert.Equal(t, "keep", decoded["description"]) +} + +func TestResolveRefsInNode_DefaultBranchForScalarNode(t *testing.T) { + var root yaml.Node + err := yaml.Unmarshal([]byte("openapi: 3.0.0"), &root) + assert.NoError(t, err) + idx := NewSpecIndexWithConfig(&root, CreateClosedAPIIndexConfig()) + + scalar := &yaml.Node{Kind: yaml.ScalarNode, Value: "literal"} + out := resolveRefsInNode(scalar, idx, map[string]struct{}{}) + assert.Same(t, scalar, out) +} + +func TestResolveRefsInNode_InternalNilGuards(t *testing.T) { + var root yaml.Node + err := yaml.Unmarshal([]byte("openapi: 3.0.0"), &root) + assert.NoError(t, err) + idx := NewSpecIndexWithConfig(&root, CreateClosedAPIIndexConfig()) + + assert.Nil(t, resolveRefsInNode(nil, idx, map[string]struct{}{})) + + node := &yaml.Node{Kind: yaml.MappingNode} + assert.Same(t, node, resolveRefsInNode(node, nil, map[string]struct{}{})) +} + +func TestMergeResolvedMappingWithSiblings_OverridesAndSkipsNilKeys(t *testing.T) { + resolved := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "type"}, + {Kind: yaml.ScalarNode, Value: "object"}, + }, + } + siblings := []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "type"}, + {Kind: yaml.ScalarNode, Value: "string"}, + nil, + {Kind: yaml.ScalarNode, Value: "ignored"}, + {Kind: yaml.ScalarNode, Value: "description"}, + {Kind: yaml.ScalarNode, Value: "ok"}, + } + + merged := mergeResolvedMappingWithSiblings(resolved, siblings) + assert.NotNil(t, merged) + + var decoded map[string]interface{} + err := merged.Decode(&decoded) + assert.NoError(t, err) + assert.Equal(t, "string", decoded["type"]) + assert.Equal(t, "ok", decoded["description"]) +} + +func findMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i] != nil && node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +}