Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions datamodel/low/v2/swagger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions datamodel/low/v2/swagger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions datamodel/low/v3/create_document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions datamodel/low/v3/create_document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions index/index_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 23 additions & 21 deletions index/index_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func TestSpecIndexConfig_ToDocumentConfiguration_AllFields(t *testing.T) {
UseSchemaQuickHash: true,
AllowUnknownExtensionContentDetection: true,
TransformSiblingRefs: true,
ResolveNestedRefsWithDocumentContext: true,
}

result := config.ToDocumentConfiguration()
Expand All @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
95 changes: 95 additions & 0 deletions index/resolve_reference_value.go
Original file line number Diff line number Diff line change
@@ -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", "~")
}
Loading
Loading