Skip to content
Merged
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
6 changes: 6 additions & 0 deletions datamodel/high/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ type Schema struct {
// in 3.1 Items can be a Schema or a boolean
Items *DynamicValue[*SchemaProxy, bool] `json:"items,omitempty" yaml:"items,omitempty"`

// 3.1+ only, JSON Schema 2020-12 $id - declares this schema as a schema resource with a URI identifier
Id string `json:"$id,omitempty" yaml:"$id,omitempty"`

// 3.1 only, part of the JSON Schema spec provides a way to identify a sub-schema
Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"`

Expand Down Expand Up @@ -312,6 +315,9 @@ func NewSchema(schema *base.Schema) *Schema {
}
s.Required = req

if !schema.Id.IsEmpty() {
s.Id = schema.Id.Value
}
if !schema.Anchor.IsEmpty() {
s.Anchor = schema.Anchor.Value
}
Expand Down
37 changes: 37 additions & 0 deletions datamodel/high/base/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1870,3 +1870,40 @@ func TestSchema_RenderInlineWithContext_Error(t *testing.T) {
assert.Nil(t, result)
assert.Contains(t, err.Error(), "circular reference")
}

// TestNewSchema_Id tests that the $id field is correctly mapped from low to high level
func TestNewSchema_Id(t *testing.T) {
yml := `type: object
$id: "https://example.com/schemas/pet.json"
description: A pet schema`

var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

var lowSch lowbase.Schema
_ = low.BuildModel(idxNode.Content[0], &lowSch)
_ = lowSch.Build(context.Background(), idxNode.Content[0], nil)

highSch := NewSchema(&lowSch)

assert.Equal(t, "https://example.com/schemas/pet.json", highSch.Id)
assert.Equal(t, "object", highSch.Type[0])
assert.Equal(t, "A pet schema", highSch.Description)
}

// TestNewSchema_Id_Empty tests that empty $id results in empty string
func TestNewSchema_Id_Empty(t *testing.T) {
yml := `type: object
description: A schema without $id`

var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

var lowSch lowbase.Schema
_ = low.BuildModel(idxNode.Content[0], &lowSch)
_ = lowSch.Build(context.Background(), idxNode.Content[0], nil)

highSch := NewSchema(&lowSch)

assert.Equal(t, "", highSch.Id)
}
1 change: 1 addition & 0 deletions datamodel/low/base/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const (
ExclusiveMaximumLabel = "exclusiveMaximum"
SchemaLabel = "schema"
SchemaTypeLabel = "$schema"
IdLabel = "$id"
AnchorLabel = "$anchor"
DynamicAnchorLabel = "$dynamicAnchor"
DynamicRefLabel = "$dynamicRef"
Expand Down
13 changes: 13 additions & 0 deletions datamodel/low/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type Schema struct {
PropertyNames low.NodeReference[*SchemaProxy]
UnevaluatedItems low.NodeReference[*SchemaProxy]
UnevaluatedProperties low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]
Id low.NodeReference[string] // JSON Schema 2020-12 $id - schema resource identifier
Anchor low.NodeReference[string]
DynamicAnchor low.NodeReference[string]
DynamicRef low.NodeReference[string]
Expand Down Expand Up @@ -489,6 +490,10 @@ func (s *Schema) hash(quick bool) [32]byte {
sb.WriteString(low.GenerateHashString(s.UnevaluatedItems.Value))
sb.WriteByte('|')
}
if !s.Id.IsEmpty() {
sb.WriteString(s.Id.Value)
sb.WriteByte('|')
}
if !s.Anchor.IsEmpty() {
sb.WriteString(s.Anchor.Value)
sb.WriteByte('|')
Expand Down Expand Up @@ -830,6 +835,14 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde
}
}

// handle $id if set. (3.1+, JSON Schema 2020-12)
_, idLabel, idNode := utils.FindKeyNodeFullTop(IdLabel, root.Content)
if idNode != nil {
s.Id = low.NodeReference[string]{
Value: idNode.Value, KeyNode: idLabel, ValueNode: idNode,
}
}

// handle anchor if set. (3.1)
_, anchorLabel, anchorNode := utils.FindKeyNodeFullTop(AnchorLabel, root.Content)
if anchorNode != nil {
Expand Down
78 changes: 78 additions & 0 deletions datamodel/low/base/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2675,3 +2675,81 @@ func TestSchemaDynamicValue_Hash_IsB(t *testing.T) {
assert.False(t, value.IsA())
assert.True(t, value.IsB())
}

// TestSchema_Id tests that the $id field is correctly extracted and included in the hash
func TestSchema_Id(t *testing.T) {
yml := `type: object
$id: "https://example.com/schemas/pet.json"
description: A pet schema`

var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

var sch Schema
err := low.BuildModel(idxNode.Content[0], &sch)
assert.NoError(t, err)

err = sch.Build(context.Background(), idxNode.Content[0], nil)
assert.NoError(t, err)

assert.Equal(t, "https://example.com/schemas/pet.json", sch.Id.Value)
assert.NotNil(t, sch.Id.KeyNode)
assert.NotNil(t, sch.Id.ValueNode)
}

// TestSchema_Id_Hash tests that $id is included in the schema hash
func TestSchema_Id_Hash(t *testing.T) {
yml1 := `type: object
$id: "https://example.com/schemas/a.json"
description: Schema A`

yml2 := `type: object
$id: "https://example.com/schemas/b.json"
description: Schema A`

yml3 := `type: object
description: Schema A`

var node1, node2, node3 yaml.Node
_ = yaml.Unmarshal([]byte(yml1), &node1)
_ = yaml.Unmarshal([]byte(yml2), &node2)
_ = yaml.Unmarshal([]byte(yml3), &node3)

var sch1, sch2, sch3 Schema
_ = low.BuildModel(node1.Content[0], &sch1)
_ = sch1.Build(context.Background(), node1.Content[0], nil)

_ = low.BuildModel(node2.Content[0], &sch2)
_ = sch2.Build(context.Background(), node2.Content[0], nil)

_ = low.BuildModel(node3.Content[0], &sch3)
_ = sch3.Build(context.Background(), node3.Content[0], nil)

hash1 := sch1.Hash()
hash2 := sch2.Hash()
hash3 := sch3.Hash()

// Different $id values should produce different hashes
assert.NotEqual(t, hash1, hash2)
// Schema without $id should differ from schema with $id
assert.NotEqual(t, hash1, hash3)
assert.NotEqual(t, hash2, hash3)
}

// TestSchema_Id_Empty tests that empty $id is not set
func TestSchema_Id_Empty(t *testing.T) {
yml := `type: object
description: A schema without $id`

var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

var sch Schema
err := low.BuildModel(idxNode.Content[0], &sch)
assert.NoError(t, err)

err = sch.Build(context.Background(), idxNode.Content[0], nil)
assert.NoError(t, err)

assert.True(t, sch.Id.IsEmpty())
}
114 changes: 113 additions & 1 deletion index/extract_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ import (
// Set LIBOPENAPI_LEGACY_REF_ORDER=true to use the old non-deterministic ordering.
var preserveLegacyRefOrder = os.Getenv("LIBOPENAPI_LEGACY_REF_ORDER") == "true"

// findSchemaIdInNode looks for a $id key in a mapping node and returns its value.
// Returns empty string if not found or if the node is not a mapping.
func findSchemaIdInNode(node *yaml.Node) string {
if node == nil || node.Kind != yaml.MappingNode {
return ""
}
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Value == "$id" && utils.IsNodeStringValue(node.Content[i+1]) {
return node.Content[i+1].Value
}
}
return ""
}

// indexedRef pairs a resolved reference with its original input position for deterministic ordering.
type indexedRef struct {
ref *Reference
Expand All @@ -38,6 +52,33 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
if node == nil {
return nil
}

// Initialize $id scope if not present (uses document base as initial scope)
scope := GetSchemaIdScope(ctx)
if scope == nil {
scope = NewSchemaIdScope(index.specAbsolutePath)
ctx = WithSchemaIdScope(ctx, scope)
}

// Capture the parent's base URI BEFORE any $id in this node is processed
// This is used for registering any $id found in this node
parentBaseUri := scope.BaseUri

// Check if THIS node has a $id and update scope for processing children
// This must happen before iterating children so they see the updated scope
if node.Kind == yaml.MappingNode {
if nodeId := findSchemaIdInNode(node); nodeId != "" {
resolvedNodeId, _ := ResolveSchemaId(nodeId, parentBaseUri)
if resolvedNodeId == "" {
resolvedNodeId = nodeId
}
// Update scope for children of this node
scope = scope.Copy()
scope.PushId(resolvedNodeId)
ctx = WithSchemaIdScope(ctx, scope)
}
}

var found []*Reference
if len(node.Content) > 0 {
var prev, polyName string
Expand All @@ -54,6 +95,7 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
polyName = prev
}
}

found = append(found, index.ExtractRefs(ctx, n, node, seenPath, level, poly, polyName)...)
}

Expand Down Expand Up @@ -510,7 +552,77 @@ func (index *SpecIndex) ExtractRefs(ctx context.Context, node, parent *yaml.Node
}
}

if i%2 == 0 && n.Value != "$ref" && n.Value != "" {
// Detect and register JSON Schema 2020-12 $id declarations
if i%2 == 0 && n.Value == "$id" {
if len(node.Content) > i+1 && utils.IsNodeStringValue(node.Content[i+1]) {
idValue := node.Content[i+1].Value
idNode := node.Content[i+1]

// Build the definition path for this schema
var definitionPath string
if len(seenPath) > 0 {
definitionPath = "#/" + strings.Join(seenPath, "/")
} else {
definitionPath = "#"
}

// Validate the $id (must not contain fragment)
if err := ValidateSchemaId(idValue); err != nil {
index.errorLock.Lock()
index.refErrors = append(index.refErrors, &IndexingError{
Err: fmt.Errorf("invalid $id value '%s': %w", idValue, err),
Node: idNode,
KeyNode: node.Content[i],
Path: definitionPath,
})
index.errorLock.Unlock()
continue
}

// Resolve the $id against the PARENT scope's base URI (nearest ancestor $id)
// This implements JSON Schema 2020-12 hierarchical $id resolution
// We use parentBaseUri which was captured before this node's $id was pushed
baseUri := parentBaseUri
if baseUri == "" {
baseUri = index.specAbsolutePath
}
resolvedUri, resolveErr := ResolveSchemaId(idValue, baseUri)
if resolveErr != nil {
if index.logger != nil {
index.logger.Warn("failed to resolve $id",
"id", idValue,
"base", baseUri,
"definitionPath", definitionPath,
"error", resolveErr.Error(),
"line", idNode.Line)
}
resolvedUri = idValue // Use original as fallback
}

// Create and register the schema ID entry
// ParentId is the parent scope's base URI (if it differs from document base)
parentId := ""
if parentBaseUri != index.specAbsolutePath && parentBaseUri != "" {
parentId = parentBaseUri
}
entry := &SchemaIdEntry{
Id: idValue,
ResolvedUri: resolvedUri,
SchemaNode: node,
ParentId: parentId,
Index: index,
DefinitionPath: definitionPath,
Line: idNode.Line,
Column: idNode.Column,
}

// Register in the index (validation already done above)
_ = index.RegisterSchemaId(entry)
}
}

// Skip $ref and $id from path building - they are keywords, not schema properties
if i%2 == 0 && n.Value != "$ref" && n.Value != "$id" && n.Value != "" {

v := n.Value
if strings.HasPrefix(v, "/") {
Expand Down
2 changes: 2 additions & 0 deletions index/index_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ type SpecIndex struct {
nodeMapCompleted chan struct{}
pendingResolve []refMap
highModelCache Cache
schemaIdRegistry map[string]*SchemaIdEntry // registry of $id declarations for JSON Schema 2020-12
schemaIdRegistryLock sync.RWMutex // lock for concurrent access to schemaIdRegistry
}

// GetResolver returns the resolver for this index.
Expand Down
Loading