Skip to content

Commit e557409

Browse files
feat: add reference chain tracking API for nested schema references (#119)
1 parent f38f2bd commit e557409

File tree

4 files changed

+632
-1
lines changed

4 files changed

+632
-1
lines changed

jsonschema/oas3/resolution.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,83 @@ func (r *JSONSchema[Referenceable]) GetReferenceResolutionInfo() *references.Res
167167
return r.referenceResolutionCache
168168
}
169169

170+
// ReferenceChainEntry represents a step in the reference resolution chain.
171+
// Each entry contains the schema that holds the reference and the reference itself.
172+
type ReferenceChainEntry struct {
173+
// Schema is the JSONSchema node that contains the $ref.
174+
// This is the schema that was resolved to get to the next step in the chain.
175+
Schema *JSONSchema[Referenceable]
176+
177+
// Reference is the $ref value from the schema (e.g., "#/components/schemas/User").
178+
Reference references.Reference
179+
}
180+
181+
// GetReferenceChain returns the chain of references that were followed to resolve this schema.
182+
// The chain is ordered from the outermost reference (top-level parent) to the innermost (immediate parent).
183+
// Returns nil if this schema was not resolved via references.
184+
//
185+
// Example: If a response schema references Schema1, which references SchemaShared,
186+
// calling GetReferenceChain() on the resolved SchemaShared would return:
187+
// - [0]: response schema with reference "#/components/schemas/Schema1"
188+
// - [1]: Schema1 with reference "#/components/schemas/SchemaShared"
189+
//
190+
// This allows tracking which schemas first referenced nested schemas during iteration.
191+
func (j *JSONSchema[T]) GetReferenceChain() []*ReferenceChainEntry {
192+
if j == nil || j.parent == nil {
193+
return nil
194+
}
195+
196+
var chain []*ReferenceChainEntry
197+
198+
// Walk from the immediate parent up to the top-level
199+
current := j.parent
200+
for current != nil {
201+
if current.IsReference() {
202+
entry := &ReferenceChainEntry{
203+
Schema: current,
204+
Reference: current.GetRef(),
205+
}
206+
// Prepend to get topLevel first (outer -> inner order)
207+
chain = append([]*ReferenceChainEntry{entry}, chain...)
208+
}
209+
210+
// Move to the parent of current
211+
current = current.GetParent()
212+
}
213+
214+
return chain
215+
}
216+
217+
// GetImmediateReference returns the immediate parent reference that resolved to this schema.
218+
// Returns nil if this schema was not resolved via a reference.
219+
//
220+
// This is a convenience method equivalent to getting the last element of GetReferenceChain().
221+
func (j *JSONSchema[T]) GetImmediateReference() *ReferenceChainEntry {
222+
if j == nil || j.parent == nil || !j.parent.IsReference() {
223+
return nil
224+
}
225+
226+
return &ReferenceChainEntry{
227+
Schema: j.parent,
228+
Reference: j.parent.GetRef(),
229+
}
230+
}
231+
232+
// GetTopLevelReference returns the outermost (first) reference in the chain that led to this schema.
233+
// Returns nil if this schema was not resolved via a reference.
234+
//
235+
// This is a convenience method equivalent to getting the first element of GetReferenceChain().
236+
func (j *JSONSchema[T]) GetTopLevelReference() *ReferenceChainEntry {
237+
if j == nil || j.topLevelParent == nil || !j.topLevelParent.IsReference() {
238+
return nil
239+
}
240+
241+
return &ReferenceChainEntry{
242+
Schema: j.topLevelParent,
243+
Reference: j.topLevelParent.GetRef(),
244+
}
245+
}
246+
170247
func (s *JSONSchema[Referenceable]) resolve(ctx context.Context, opts references.ResolveOptions, referenceChain []string) ([]string, []error, error) {
171248
if !s.IsReference() {
172249
return referenceChain, nil, nil

jsonschema/oas3/resolution_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,3 +1055,162 @@ func TestJSONSchema_ParentLinks(t *testing.T) {
10551055
}, "SetTopLevelParent on nil schema should not panic")
10561056
})
10571057
}
1058+
1059+
// Test GetReferenceChain method
1060+
func TestJSONSchema_GetReferenceChain(t *testing.T) {
1061+
t.Parallel()
1062+
1063+
t.Run("nil schema returns nil", func(t *testing.T) {
1064+
t.Parallel()
1065+
var nilSchema *JSONSchema[Referenceable]
1066+
assert.Nil(t, nilSchema.GetReferenceChain(), "nil schema GetReferenceChain should return nil")
1067+
})
1068+
1069+
t.Run("schema with nil parent returns nil", func(t *testing.T) {
1070+
t.Parallel()
1071+
schema := createSimpleSchema()
1072+
assert.Nil(t, schema.GetReferenceChain(), "schema with nil parent should return nil from GetReferenceChain")
1073+
})
1074+
1075+
t.Run("schema with non-reference parent returns empty chain", func(t *testing.T) {
1076+
t.Parallel()
1077+
// Create parent that is NOT a reference (just a regular schema)
1078+
nonRefParent := createSimpleSchema()
1079+
1080+
// Create child with parent set
1081+
childSchema := createSimpleSchema()
1082+
childSchema.SetParent(nonRefParent)
1083+
1084+
// Chain should be empty (not nil) since parent exists but isn't a reference
1085+
chain := childSchema.GetReferenceChain()
1086+
assert.Empty(t, chain, "schema with non-reference parent should return empty chain")
1087+
})
1088+
1089+
t.Run("schema with reference parent returns single-entry chain", func(t *testing.T) {
1090+
t.Parallel()
1091+
// Create parent that IS a reference
1092+
refParent := createSchemaWithRef("#/components/schemas/Parent")
1093+
1094+
// Create child with parent set
1095+
childSchema := createSimpleSchema()
1096+
childSchema.SetParent(refParent)
1097+
1098+
chain := childSchema.GetReferenceChain()
1099+
require.Len(t, chain, 1, "schema with reference parent should return single-entry chain")
1100+
assert.Equal(t, "#/components/schemas/Parent", string(chain[0].Reference))
1101+
assert.Equal(t, refParent, chain[0].Schema)
1102+
})
1103+
1104+
t.Run("schema with mixed parent chain filters non-references", func(t *testing.T) {
1105+
t.Parallel()
1106+
// Create a chain: refGrandparent -> nonRefParent -> child
1107+
// Only refGrandparent should appear in the chain
1108+
1109+
refGrandparent := createSchemaWithRef("#/components/schemas/Grandparent")
1110+
nonRefParent := createSimpleSchema()
1111+
childSchema := createSimpleSchema()
1112+
1113+
// Set up the chain
1114+
nonRefParent.SetParent(refGrandparent)
1115+
childSchema.SetParent(nonRefParent)
1116+
1117+
chain := childSchema.GetReferenceChain()
1118+
require.Len(t, chain, 1, "chain should only include reference parents")
1119+
assert.Equal(t, "#/components/schemas/Grandparent", string(chain[0].Reference))
1120+
})
1121+
1122+
t.Run("schema with multiple reference ancestors returns full chain", func(t *testing.T) {
1123+
t.Parallel()
1124+
// Create a chain: refGrandparent -> refParent -> child
1125+
1126+
refGrandparent := createSchemaWithRef("#/components/schemas/Grandparent")
1127+
refParent := createSchemaWithRef("#/components/schemas/Parent")
1128+
childSchema := createSimpleSchema()
1129+
1130+
// Set up the chain
1131+
refParent.SetParent(refGrandparent)
1132+
childSchema.SetParent(refParent)
1133+
1134+
chain := childSchema.GetReferenceChain()
1135+
require.Len(t, chain, 2, "chain should include both reference ancestors")
1136+
// Chain is outer -> inner order (grandparent first, parent last)
1137+
assert.Equal(t, "#/components/schemas/Grandparent", string(chain[0].Reference))
1138+
assert.Equal(t, "#/components/schemas/Parent", string(chain[1].Reference))
1139+
})
1140+
}
1141+
1142+
// Test GetImmediateReference method
1143+
func TestJSONSchema_GetImmediateReference(t *testing.T) {
1144+
t.Parallel()
1145+
1146+
t.Run("nil schema returns nil", func(t *testing.T) {
1147+
t.Parallel()
1148+
var nilSchema *JSONSchema[Referenceable]
1149+
assert.Nil(t, nilSchema.GetImmediateReference(), "nil schema GetImmediateReference should return nil")
1150+
})
1151+
1152+
t.Run("schema with nil parent returns nil", func(t *testing.T) {
1153+
t.Parallel()
1154+
schema := createSimpleSchema()
1155+
assert.Nil(t, schema.GetImmediateReference(), "schema with nil parent should return nil")
1156+
})
1157+
1158+
t.Run("schema with non-reference parent returns nil", func(t *testing.T) {
1159+
t.Parallel()
1160+
nonRefParent := createSimpleSchema()
1161+
childSchema := createSimpleSchema()
1162+
childSchema.SetParent(nonRefParent)
1163+
1164+
assert.Nil(t, childSchema.GetImmediateReference(), "schema with non-reference parent should return nil")
1165+
})
1166+
1167+
t.Run("schema with reference parent returns entry", func(t *testing.T) {
1168+
t.Parallel()
1169+
refParent := createSchemaWithRef("#/components/schemas/Parent")
1170+
childSchema := createSimpleSchema()
1171+
childSchema.SetParent(refParent)
1172+
1173+
entry := childSchema.GetImmediateReference()
1174+
require.NotNil(t, entry, "should return entry for reference parent")
1175+
assert.Equal(t, "#/components/schemas/Parent", string(entry.Reference))
1176+
assert.Equal(t, refParent, entry.Schema)
1177+
})
1178+
}
1179+
1180+
// Test GetTopLevelReference method
1181+
func TestJSONSchema_GetTopLevelReference(t *testing.T) {
1182+
t.Parallel()
1183+
1184+
t.Run("nil schema returns nil", func(t *testing.T) {
1185+
t.Parallel()
1186+
var nilSchema *JSONSchema[Referenceable]
1187+
assert.Nil(t, nilSchema.GetTopLevelReference(), "nil schema GetTopLevelReference should return nil")
1188+
})
1189+
1190+
t.Run("schema with nil topLevelParent returns nil", func(t *testing.T) {
1191+
t.Parallel()
1192+
schema := createSimpleSchema()
1193+
assert.Nil(t, schema.GetTopLevelReference(), "schema with nil topLevelParent should return nil")
1194+
})
1195+
1196+
t.Run("schema with non-reference topLevelParent returns nil", func(t *testing.T) {
1197+
t.Parallel()
1198+
nonRefTopLevel := createSimpleSchema()
1199+
childSchema := createSimpleSchema()
1200+
childSchema.SetTopLevelParent(nonRefTopLevel)
1201+
1202+
assert.Nil(t, childSchema.GetTopLevelReference(), "schema with non-reference topLevelParent should return nil")
1203+
})
1204+
1205+
t.Run("schema with reference topLevelParent returns entry", func(t *testing.T) {
1206+
t.Parallel()
1207+
refTopLevel := createSchemaWithRef("#/components/schemas/TopLevel")
1208+
childSchema := createSimpleSchema()
1209+
childSchema.SetTopLevelParent(refTopLevel)
1210+
1211+
entry := childSchema.GetTopLevelReference()
1212+
require.NotNil(t, entry, "should return entry for reference topLevelParent")
1213+
assert.Equal(t, "#/components/schemas/TopLevel", string(entry.Reference))
1214+
assert.Equal(t, refTopLevel, entry.Schema)
1215+
})
1216+
}

0 commit comments

Comments
 (0)