Skip to content

Commit 58b79e9

Browse files
feat: add support for $id and $anchor reference resolution (#123)
1 parent 59985e9 commit 58b79e9

38 files changed

+4774
-363
lines changed

arazzo/criterion/criterion.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func (c *CriterionTypeUnion) Populate(source any) error {
147147
c.Type = &typ
148148
} else if coreCriterionTypeUnion.ExpressionType != nil {
149149
c.ExpressionType = &CriterionExpressionType{}
150-
if err := marshaller.Populate(*coreCriterionTypeUnion.ExpressionType, c.ExpressionType); err != nil {
150+
if err := marshaller.PopulateWithContext(*coreCriterionTypeUnion.ExpressionType, c.ExpressionType, nil); err != nil {
151151
return err
152152
}
153153
}

extensions/extensions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func UnmarshalExtensionModel[H any, L any](ctx context.Context, e *Extensions, e
112112

113113
var mV H
114114

115-
if err := marshaller.Populate(*c, &mV); err != nil {
115+
if err := marshaller.PopulateWithContext(*c, &mV, nil); err != nil {
116116
return nil, err
117117
}
118118
*m = mV

extensions/extensions_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func getTestModelWithExtensions(ctx context.Context, t *testing.T, data string)
122122
require.Empty(t, validationErrs)
123123

124124
m := &ModelWithExtensions{}
125-
err = marshaller.Populate(c, m)
125+
err = marshaller.PopulateWithContext(c, m, nil)
126126
require.NoError(t, err)
127127

128128
return m

jsonschema/oas3/core/jsonschema.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Schema struct {
3535
UnevaluatedProperties marshaller.Node[JSONSchema] `key:"unevaluatedProperties"`
3636
Items marshaller.Node[JSONSchema] `key:"items"`
3737
Anchor marshaller.Node[*string] `key:"$anchor"`
38+
ID marshaller.Node[*string] `key:"$id"`
3839
Not marshaller.Node[JSONSchema] `key:"not"`
3940
Properties marshaller.Node[*sequencedmap.Map[string, JSONSchema]] `key:"properties"`
4041
Defs marshaller.Node[*sequencedmap.Map[string, JSONSchema]] `key:"$defs"`

jsonschema/oas3/jsonschema.go

Lines changed: 147 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package oas3
22

33
import (
44
"context"
5+
"strings"
56
"unsafe"
67

78
"github.com/speakeasy-api/openapi/extensions"
89
"github.com/speakeasy-api/openapi/jsonschema/oas3/core"
10+
"github.com/speakeasy-api/openapi/marshaller"
911
"github.com/speakeasy-api/openapi/pointer"
1012
"github.com/speakeasy-api/openapi/references"
1113
"github.com/speakeasy-api/openapi/validation"
@@ -38,6 +40,20 @@ type JSONSchema[T Referenceable | Concrete] struct {
3840
// parent = intermediate reference, topLevelParent = original reference
3941
parent *JSONSchema[Referenceable] // Immediate parent reference in the chain
4042
topLevelParent *JSONSchema[Referenceable] // Top-level parent (root of the reference chain)
43+
44+
// enclosingSchema is the Schema that contains this JSONSchema as a field.
45+
// This is used during population to determine the parent's effective base URI
46+
// for relative $id resolution. Set during PopulateWithContext.
47+
enclosingSchema *Schema
48+
49+
// schemaRegistry stores $id and $anchor mappings for this document.
50+
// Used for standalone JSON Schema documents that are not embedded in an OpenAPI document.
51+
// For embedded schemas, the owning OpenAPI document's registry is used instead.
52+
schemaRegistry SchemaRegistry
53+
54+
// documentBaseURI is the base URI for this standalone JSON Schema document.
55+
// This is typically derived from the $id keyword or empty if not specified.
56+
documentBaseURI string
4157
}
4258

4359
var _ references.Resolvable[JSONSchema[Concrete]] = (*JSONSchema[Referenceable])(nil)
@@ -281,6 +297,9 @@ func (j *JSONSchema[T]) ShallowCopy() *JSONSchema[T] {
281297
resolvedSchemaCache: j.resolvedSchemaCache,
282298
parent: j.parent,
283299
topLevelParent: j.topLevelParent,
300+
enclosingSchema: j.enclosingSchema,
301+
schemaRegistry: j.schemaRegistry,
302+
documentBaseURI: j.documentBaseURI,
284303
}
285304

286305
// Shallow copy the EitherValue contents
@@ -295,23 +314,137 @@ func (j *JSONSchema[T]) ShallowCopy() *JSONSchema[T] {
295314
return result
296315
}
297316

298-
// PopulateWithParent implements the ParentAwarePopulator interface to establish parent relationships during population
299-
func (j *JSONSchema[T]) PopulateWithParent(source any, parent any) error {
300-
// If we have a parent that is also a JSONSchema, establish the parent relationship
301-
if parent != nil {
302-
if parentSchema, ok := parent.(*Schema); ok {
303-
j.SetParent(parentSchema.GetParent())
304-
// If the parent has a top-level parent, inherit it; otherwise, the parent is the top-level
305-
if parentSchema.GetParent().GetTopLevelParent() != nil {
306-
j.SetTopLevelParent(parentSchema.GetParent().GetTopLevelParent())
307-
} else {
308-
j.SetTopLevelParent(parentSchema.GetParent())
309-
}
317+
// GetSchemaRegistry returns the schema registry for this standalone JSON Schema document.
318+
// The registry stores $id and $anchor mappings for efficient schema resolution.
319+
// If the registry has not been initialized, it creates one with the document's base URI.
320+
// This implements the SchemaRegistryProvider interface.
321+
func (j *JSONSchema[T]) GetSchemaRegistry() SchemaRegistry {
322+
if j == nil {
323+
return nil
324+
}
325+
326+
// Lazily initialize the registry if needed
327+
if j.schemaRegistry == nil {
328+
j.schemaRegistry = NewSchemaRegistry(j.GetDocumentBaseURI())
329+
}
330+
331+
return j.schemaRegistry
332+
}
333+
334+
// GetDocumentBaseURI returns the base URI for this standalone JSON Schema document.
335+
// This is derived from the $id keyword of the root schema, if present.
336+
// The returned URI is normalized and stripped of any fragment to align with registry behavior.
337+
// This implements the SchemaRegistryProvider interface.
338+
func (j *JSONSchema[T]) GetDocumentBaseURI() string {
339+
if j == nil {
340+
return ""
341+
}
342+
343+
var uri string
344+
345+
// If we have an explicit document base URI set, use it
346+
if j.documentBaseURI != "" {
347+
uri = j.documentBaseURI
348+
} else if j.IsSchema() && j.GetSchema() != nil {
349+
// Try to get from the root schema's $id
350+
uri = j.GetSchema().GetID()
351+
}
352+
353+
if uri == "" {
354+
return ""
355+
}
356+
357+
// Strip fragment and normalize to align with registry behavior
358+
// Per JSON Schema spec, $id should not contain fragments, but we strip for robustness
359+
return normalizeDocumentBaseURI(uri)
360+
}
361+
362+
// normalizeDocumentBaseURI strips fragments and normalizes a URI for use as a document base.
363+
func normalizeDocumentBaseURI(uri string) string {
364+
if uri == "" {
365+
return ""
366+
}
367+
368+
// Strip fragment if present
369+
if idx := strings.Index(uri, "#"); idx != -1 {
370+
uri = uri[:idx]
371+
}
372+
373+
// Use the same normalization as the registry
374+
return normalizeURI(uri)
375+
}
376+
377+
// SetSchemaRegistry sets the schema registry for this document.
378+
// This is primarily used during unmarshalling to set a pre-created registry.
379+
func (j *JSONSchema[T]) SetSchemaRegistry(registry SchemaRegistry) {
380+
if j == nil {
381+
return
382+
}
383+
j.schemaRegistry = registry
384+
}
385+
386+
// SetDocumentBaseURI sets the document base URI for this standalone JSON Schema.
387+
func (j *JSONSchema[T]) SetDocumentBaseURI(uri string) {
388+
if j == nil {
389+
return
390+
}
391+
j.documentBaseURI = uri
392+
}
393+
394+
// GetEnclosingSchema returns the Schema that contains this JSONSchema as a field.
395+
// This is used during population to access the parent schema's effective base URI
396+
// for relative $id resolution. Returns nil if not set.
397+
func (j *JSONSchema[T]) GetEnclosingSchema() *Schema {
398+
if j == nil {
399+
return nil
400+
}
401+
return j.enclosingSchema
402+
}
403+
404+
// SetEnclosingSchema sets the Schema that contains this JSONSchema as a field.
405+
// This is used during population to establish parent-child relationships
406+
// for effective base URI computation.
407+
func (j *JSONSchema[T]) SetEnclosingSchema(schema *Schema) {
408+
if j == nil {
409+
return
410+
}
411+
j.enclosingSchema = schema
412+
}
413+
414+
// PopulateWithContext implements the ContextAwarePopulator interface for full context-aware population.
415+
// This method receives the owning document and propagates it to the contained Schema.
416+
func (j *JSONSchema[T]) PopulateWithContext(source any, ctx *marshaller.PopulationContext) error {
417+
// If we have a parent that is a Schema, store it as enclosingSchema.
418+
// This is critical for relative $id resolution - children need to access parent's effective base URI.
419+
//
420+
// Note: We only set enclosingSchema here (document tree relationship).
421+
// The parent/topLevelParent fields are for reference chains and are set during
422+
// reference resolution, not population. See documentation on those fields (lines 28-41).
423+
if ctx != nil && ctx.Parent != nil {
424+
if parentSchema, ok := ctx.Parent.(*Schema); ok {
425+
j.enclosingSchema = parentSchema
310426
}
311427
}
312428

313-
// First, perform the standard population
314-
if err := j.EitherValue.PopulateWithParent(source, j); err != nil {
429+
// Determine the owning document for context propagation
430+
// If we're not nested in any other document, this JSONSchema becomes its own owning document
431+
var owningDoc any
432+
if ctx != nil && ctx.OwningDocument != nil {
433+
owningDoc = ctx.OwningDocument
434+
} else {
435+
// This is a standalone JSON Schema document - use itself as the owning document
436+
owningDoc = j
437+
}
438+
439+
// Create a new context with this JSONSchema as the parent for nested schemas
440+
// Note: The Schema's PopulateWithContext will use 'j' (this JSONSchema) as its parent reference
441+
childCtx := &marshaller.PopulationContext{
442+
Parent: j,
443+
OwningDocument: owningDoc,
444+
}
445+
446+
// Perform the standard population with context through the EitherValue
447+
if err := j.EitherValue.PopulateWithContext(source, childCtx); err != nil {
315448
return err
316449
}
317450

0 commit comments

Comments
 (0)