Skip to content

Commit 39e91d7

Browse files
committed
Early $id resolution check so absolute URI refs no longer jump straight into rolodex lookup
1 parent e55896a commit 39e91d7

4 files changed

Lines changed: 284 additions & 42 deletions

File tree

index/find_component.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ func (index *SpecIndex) FindComponent(ctx context.Context, componentId string) *
2626
return nil
2727
}
2828

29+
if resolved := index.ResolveRefViaSchemaId(componentId); resolved != nil {
30+
return resolved
31+
}
32+
2933
if strings.HasPrefix(componentId, "/") {
3034
baseUri, fragment := SplitRefFragment(componentId)
3135
if resolved := index.resolveRefViaSchemaIdPath(baseUri); resolved != nil {

index/schema_id_resolve.go

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,62 @@ func SplitRefFragment(ref string) (baseUri string, fragment string) {
128128
return ref[:idx], ref[idx:]
129129
}
130130

131+
func joinSchemaIdDefinitionPath(definitionPath, fragment string) string {
132+
if definitionPath == "" {
133+
return ""
134+
}
135+
normalizedFragment := strings.TrimPrefix(fragment, "#")
136+
if normalizedFragment == "" || normalizedFragment == "/" {
137+
return definitionPath
138+
}
139+
if definitionPath == "#" {
140+
return "#" + normalizedFragment
141+
}
142+
return strings.TrimRight(definitionPath, "/") + normalizedFragment
143+
}
144+
145+
func buildSchemaIdResolvedReference(index *SpecIndex, entry *SchemaIdEntry, originalRef, baseUri, fragment string) *Reference {
146+
if entry == nil {
147+
return nil
148+
}
149+
150+
node := entry.SchemaNode
151+
if fragment != "" && entry.SchemaNode != nil {
152+
if fragmentNode := navigateToFragment(entry.SchemaNode, fragment); fragmentNode != nil {
153+
node = fragmentNode
154+
}
155+
}
156+
157+
definition := originalRef
158+
fullDefinition := originalRef
159+
if entry.DefinitionPath != "" {
160+
definition = joinSchemaIdDefinitionPath(entry.DefinitionPath, fragment)
161+
fullDefinition = definition
162+
if entry.Index != nil {
163+
if specPath := entry.Index.GetSpecAbsolutePath(); specPath != "" {
164+
fullDefinition = specPath + definition
165+
}
166+
}
167+
}
168+
169+
remoteLocation := ""
170+
if entry.Index != nil {
171+
remoteLocation = entry.Index.GetSpecAbsolutePath()
172+
}
173+
174+
return &Reference{
175+
FullDefinition: fullDefinition,
176+
Definition: definition,
177+
Name: baseUri,
178+
RawRef: originalRef,
179+
SchemaIdBase: baseUri,
180+
Node: node,
181+
IsRemote: entry.Index != index,
182+
RemoteLocation: remoteLocation,
183+
Index: entry.Index,
184+
}
185+
}
186+
131187
// ResolveRefViaSchemaId attempts to resolve a $ref via the $id registry.
132188
// Implements JSON Schema 2020-12 $id-based resolution:
133189
// 1. Split ref into base URI and fragment
@@ -156,26 +212,7 @@ func (index *SpecIndex) ResolveRefViaSchemaId(ref string) *Reference {
156212
return nil
157213
}
158214

159-
r := &Reference{
160-
FullDefinition: ref,
161-
Definition: ref,
162-
Name: baseUri,
163-
RawRef: ref,
164-
SchemaIdBase: baseUri,
165-
Node: entry.SchemaNode,
166-
IsRemote: entry.Index != index,
167-
RemoteLocation: entry.Index.GetSpecAbsolutePath(),
168-
Index: entry.Index,
169-
}
170-
171-
// Navigate to fragment if present
172-
if fragment != "" && entry.SchemaNode != nil {
173-
if fragmentNode := navigateToFragment(entry.SchemaNode, fragment); fragmentNode != nil {
174-
r.Node = fragmentNode
175-
}
176-
}
177-
178-
return r
215+
return buildSchemaIdResolvedReference(index, entry, ref, baseUri, fragment)
179216
}
180217

181218
func (index *SpecIndex) resolveRefViaSchemaIdPath(path string) *Reference {
@@ -212,17 +249,7 @@ func (index *SpecIndex) resolveRefViaSchemaIdPath(path string) *Reference {
212249
}
213250

214251
baseUri := match.GetKey()
215-
return &Reference{
216-
FullDefinition: baseUri,
217-
Definition: baseUri,
218-
Name: baseUri,
219-
RawRef: path,
220-
SchemaIdBase: baseUri,
221-
Node: match.SchemaNode,
222-
IsRemote: match.Index != index,
223-
RemoteLocation: match.Index.GetSpecAbsolutePath(),
224-
Index: match.Index,
225-
}
252+
return buildSchemaIdResolvedReference(index, match, path, baseUri, "")
226253
}
227254

228255
// navigateToFragment navigates to a JSON pointer fragment within a YAML node.

index/schema_id_test.go

Lines changed: 123 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,7 @@ components:
768768
assert.NoError(t, err)
769769

770770
config := CreateClosedAPIIndexConfig()
771+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
771772
index := NewSpecIndexWithConfig(&rootNode, config)
772773
assert.NotNil(t, index)
773774

@@ -831,13 +832,16 @@ components:
831832
assert.NoError(t, err)
832833

833834
config := CreateClosedAPIIndexConfig()
835+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
834836
index := NewSpecIndexWithConfig(&rootNode, config)
835837
assert.NotNil(t, index)
836838

837839
// Test resolution by $id
838840
resolved := index.ResolveRefViaSchemaId("https://example.com/schemas/pet.json")
839841
assert.NotNil(t, resolved)
840-
assert.Equal(t, "https://example.com/schemas/pet.json", resolved.FullDefinition)
842+
assert.Equal(t, "#/components/schemas/Pet", resolved.Definition)
843+
assert.Equal(t, "https://example.com/openapi.yaml#/components/schemas/Pet", resolved.FullDefinition)
844+
assert.Equal(t, "https://example.com/schemas/pet.json", resolved.RawRef)
841845
}
842846

843847
func TestResolveRefViaSchemaId_NotFound(t *testing.T) {
@@ -852,6 +856,7 @@ info:
852856
assert.NoError(t, err)
853857

854858
config := CreateClosedAPIIndexConfig()
859+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
855860
index := NewSpecIndexWithConfig(&rootNode, config)
856861
assert.NotNil(t, index)
857862

@@ -893,12 +898,15 @@ components:
893898
assert.NoError(t, err)
894899

895900
config := CreateClosedAPIIndexConfig()
901+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
896902
index := NewSpecIndexWithConfig(&rootNode, config)
897903
assert.NotNil(t, index)
898904

899905
// Test resolution with fragment
900906
resolved := index.ResolveRefViaSchemaId("https://example.com/schemas/pet.json#/properties/name")
901907
assert.NotNil(t, resolved)
908+
assert.Equal(t, "#/components/schemas/Pet/properties/name", resolved.Definition)
909+
assert.Equal(t, "https://example.com/openapi.yaml#/components/schemas/Pet/properties/name", resolved.FullDefinition)
902910
// The resolved node should be the "name" property schema
903911
if resolved.Node != nil {
904912
// Check it's the right node (type: string)
@@ -1235,7 +1243,8 @@ info:
12351243
// ResolveRefViaSchemaId should find the schema via rolodex global registry
12361244
resolved := index2.ResolveRefViaSchemaId("https://example.com/schemas/pet.json")
12371245
assert.NotNil(t, resolved)
1238-
assert.Equal(t, "https://example.com/schemas/pet.json", resolved.FullDefinition)
1246+
assert.Equal(t, "https://example.com/openapi.yaml#/components/schemas/Pet", resolved.FullDefinition)
1247+
assert.Equal(t, "#/components/schemas/Pet", resolved.Definition)
12391248
}
12401249

12411250
// Test that FindSchemaIdInNode returns empty for non-mapping nodes
@@ -1283,6 +1292,7 @@ components:
12831292
assert.NoError(t, err)
12841293

12851294
config := CreateClosedAPIIndexConfig()
1295+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
12861296
index := NewSpecIndexWithConfig(&rootNode, config)
12871297
assert.NotNil(t, index)
12881298

@@ -1367,6 +1377,7 @@ components:
13671377
assert.NoError(t, err)
13681378

13691379
config := CreateClosedAPIIndexConfig()
1380+
config.SpecAbsolutePath = "https://example.com/openapi.yaml"
13701381
index := NewSpecIndexWithConfig(&rootNode, config)
13711382
assert.NotNil(t, index)
13721383

@@ -1382,6 +1393,106 @@ components:
13821393
assert.Equal(t, resolved1.FullDefinition, resolved2.FullDefinition)
13831394
}
13841395

1396+
func TestJoinSchemaIdDefinitionPath(t *testing.T) {
1397+
tests := []struct {
1398+
name string
1399+
definitionPath string
1400+
fragment string
1401+
expected string
1402+
}{
1403+
{
1404+
name: "empty definition path",
1405+
definitionPath: "",
1406+
fragment: "#/properties/name",
1407+
expected: "",
1408+
},
1409+
{
1410+
name: "no fragment",
1411+
definitionPath: "#/components/schemas/Pet",
1412+
fragment: "",
1413+
expected: "#/components/schemas/Pet",
1414+
},
1415+
{
1416+
name: "root definition with fragment",
1417+
definitionPath: "#",
1418+
fragment: "#/properties/name",
1419+
expected: "#/properties/name",
1420+
},
1421+
{
1422+
name: "nested definition with fragment",
1423+
definitionPath: "#/components/schemas/Pet",
1424+
fragment: "#/properties/name",
1425+
expected: "#/components/schemas/Pet/properties/name",
1426+
},
1427+
}
1428+
1429+
for _, tc := range tests {
1430+
t.Run(tc.name, func(t *testing.T) {
1431+
assert.Equal(t, tc.expected, joinSchemaIdDefinitionPath(tc.definitionPath, tc.fragment))
1432+
})
1433+
}
1434+
}
1435+
1436+
func TestBuildSchemaIdResolvedReference(t *testing.T) {
1437+
spec := `type: object
1438+
properties:
1439+
name:
1440+
type: string
1441+
`
1442+
1443+
var rootNode yaml.Node
1444+
err := yaml.Unmarshal([]byte(spec), &rootNode)
1445+
assert.NoError(t, err)
1446+
1447+
rootIndex := &SpecIndex{specAbsolutePath: "https://example.com/openapi.yaml"}
1448+
entryIndex := &SpecIndex{specAbsolutePath: "https://example.com/models.yaml"}
1449+
1450+
entry := &SchemaIdEntry{
1451+
Id: "https://example.com/schema.json",
1452+
ResolvedUri: "https://example.com/schema.json",
1453+
SchemaNode: rootNode.Content[0],
1454+
Index: entryIndex,
1455+
DefinitionPath: "#/components/schemas/Pet",
1456+
}
1457+
1458+
resolved := buildSchemaIdResolvedReference(rootIndex, entry,
1459+
"https://example.com/schema.json#/properties/name",
1460+
"https://example.com/schema.json",
1461+
"#/properties/name",
1462+
)
1463+
if assert.NotNil(t, resolved) {
1464+
assert.Equal(t, "#/components/schemas/Pet/properties/name", resolved.Definition)
1465+
assert.Equal(t, "https://example.com/models.yaml#/components/schemas/Pet/properties/name", resolved.FullDefinition)
1466+
assert.Equal(t, "https://example.com/models.yaml", resolved.RemoteLocation)
1467+
assert.True(t, resolved.IsRemote)
1468+
_, _, typeNode := utils.FindKeyNodeFullTop("type", resolved.Node.Content)
1469+
assert.NotNil(t, typeNode)
1470+
assert.Equal(t, "string", typeNode.Value)
1471+
}
1472+
1473+
fallback := buildSchemaIdResolvedReference(rootIndex, &SchemaIdEntry{
1474+
Id: "https://example.com/schema.json",
1475+
ResolvedUri: "https://example.com/schema.json",
1476+
SchemaNode: rootNode.Content[0],
1477+
}, "https://example.com/schema.json", "https://example.com/schema.json", "")
1478+
if assert.NotNil(t, fallback) {
1479+
assert.Equal(t, "https://example.com/schema.json", fallback.Definition)
1480+
assert.Equal(t, "https://example.com/schema.json", fallback.FullDefinition)
1481+
assert.True(t, fallback.IsRemote)
1482+
}
1483+
1484+
missingFragment := buildSchemaIdResolvedReference(rootIndex, entry,
1485+
"https://example.com/schema.json#/properties/unknown",
1486+
"https://example.com/schema.json",
1487+
"#/properties/unknown",
1488+
)
1489+
if assert.NotNil(t, missingFragment) {
1490+
assert.Equal(t, rootNode.Content[0], missingFragment.Node)
1491+
}
1492+
1493+
assert.Nil(t, buildSchemaIdResolvedReference(rootIndex, nil, "https://example.com/schema.json", "", ""))
1494+
}
1495+
13851496
// Test $id extraction uses document base when no scope exists
13861497
func TestSchemaId_ExtractionWithDocumentBase(t *testing.T) {
13871498
spec := `openapi: "3.1.0"
@@ -1449,7 +1560,8 @@ components:
14491560
assert.NotNil(t, ref, "Should find reference via $id")
14501561
assert.NotNil(t, foundIdx)
14511562
assert.NotNil(t, ctx)
1452-
assert.Equal(t, "https://example.com/schemas/pet.json", ref.FullDefinition)
1563+
assert.Equal(t, "#/components/schemas/Pet", ref.Definition)
1564+
assert.Equal(t, "#/components/schemas/Pet", ref.FullDefinition)
14531565
}
14541566

14551567
func TestFindComponent_AbsolutePathViaSchemaId(t *testing.T) {
@@ -1580,15 +1692,16 @@ func TestResolveRefViaSchemaIdPath_SkipsInvalidEntries(t *testing.T) {
15801692
"relative": {Id: "schemas/relative.json"},
15811693
"no-path": {ResolvedUri: "https://example.com"},
15821694
"match": {
1583-
ResolvedUri: "https://example.com/schemas/target",
1584-
SchemaNode: &yaml.Node{Kind: yaml.MappingNode},
1585-
Index: index,
1695+
ResolvedUri: "https://example.com/schemas/target",
1696+
SchemaNode: &yaml.Node{Kind: yaml.MappingNode},
1697+
Index: index,
1698+
DefinitionPath: "#/components/schemas/Target",
15861699
},
15871700
}
15881701

15891702
ref := index.resolveRefViaSchemaIdPath("/schemas/target")
15901703
assert.NotNil(t, ref)
1591-
assert.Equal(t, "https://example.com/schemas/target", ref.FullDefinition)
1704+
assert.Equal(t, "#/components/schemas/Target", ref.FullDefinition)
15921705
}
15931706

15941707
func TestResolveRefViaSchemaIdPath_UsesGlobalEntries(t *testing.T) {
@@ -1617,7 +1730,7 @@ components:
16171730

16181731
ref := localIdx.resolveRefViaSchemaIdPath("/schemas/mixins/integer")
16191732
assert.NotNil(t, ref)
1620-
assert.Equal(t, "https://example.com/schemas/mixins/integer", ref.FullDefinition)
1733+
assert.Equal(t, "#/components/schemas/Integer", ref.FullDefinition)
16211734
assert.Equal(t, globalIdx, ref.Index)
16221735
}
16231736

@@ -1712,7 +1825,8 @@ components:
17121825

17131826
found, foundIdx, _ := index.SearchIndexForReferenceByReferenceWithContext(context.Background(), searchRef)
17141827
if assert.NotNil(t, found) && assert.NotNil(t, foundIdx) {
1715-
assert.Equal(t, "https://example.com/schemas/non-negative-integer#/$defs/nonNegativeInteger", found.FullDefinition)
1828+
assert.Equal(t, "#/components/schemas/NonNegativeInteger/$defs/nonNegativeInteger", found.Definition)
1829+
assert.Equal(t, "#/components/schemas/NonNegativeInteger/$defs/nonNegativeInteger", found.FullDefinition)
17161830
}
17171831
}
17181832

0 commit comments

Comments
 (0)