diff --git a/datamodel/low/base/build_bench_test.go b/datamodel/low/base/build_bench_test.go new file mode 100644 index 000000000..2a1ab35cd --- /dev/null +++ b/datamodel/low/base/build_bench_test.go @@ -0,0 +1,289 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "go.yaml.in/yaml/v4" +) + +func benchmarkInfoRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `title: Pizza API +summary: pizza summary +description: pizza description +termsOfService: https://example.com/tos +contact: + name: Pizza Team + url: https://example.com/contact + email: pizza@example.com +license: + name: MIT + url: https://example.com/license +version: 1.0.0 +x-info: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark info: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark info: empty root") + } + return root.Content[0] +} + +func benchmarkContactRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: Pizza Team +url: https://example.com/contact +email: pizza@example.com +x-contact: + warm: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark contact: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark contact: empty root") + } + return root.Content[0] +} + +func benchmarkLicenseRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: Apache-2.0 +url: https://example.com/license +x-license: + approved: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark license: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark license: empty root") + } + return root.Content[0] +} + +func benchmarkExternalDocRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `description: more docs +url: https://example.com/docs +x-docs: + bright: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark external doc: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark external doc: empty root") + } + return root.Content[0] +} + +func benchmarkXMLRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: item +namespace: https://example.com/ns +prefix: ex +attribute: false +nodeType: element +wrapped: true +x-xml: + rich: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark xml: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark xml: empty root") + } + return root.Content[0] +} + +func benchmarkSecurityRequirementRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `oauth: + - read + - write +apiKey: + - admin` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark security requirement: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark security requirement: empty root") + } + return root.Content[0] +} + +func benchmarkTagRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: partner +summary: Partner API +description: Operations available to the partners network +parent: external +kind: audience +externalDocs: + url: https://example.com/docs + description: more docs +x-tag: + warm: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark tag: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark tag: empty root") + } + return root.Content[0] +} + +func BenchmarkInfo_Build(b *testing.B) { + rootNode := benchmarkInfoRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var info Info + if err := low.BuildModel(rootNode, &info); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := info.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("info build failed: %v", err) + } + } +} + +func BenchmarkContact_Build(b *testing.B) { + rootNode := benchmarkContactRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var contact Contact + if err := low.BuildModel(rootNode, &contact); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := contact.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("contact build failed: %v", err) + } + } +} + +func BenchmarkLicense_Build(b *testing.B) { + rootNode := benchmarkLicenseRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var license License + if err := low.BuildModel(rootNode, &license); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := license.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("license build failed: %v", err) + } + } +} + +func BenchmarkExternalDoc_Build(b *testing.B) { + rootNode := benchmarkExternalDocRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var ex ExternalDoc + if err := low.BuildModel(rootNode, &ex); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := ex.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("external doc build failed: %v", err) + } + } +} + +func BenchmarkXML_Build(b *testing.B) { + rootNode := benchmarkXMLRootNode(b) + idx := index.NewSpecIndex(rootNode) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var x XML + if err := low.BuildModel(rootNode, &x); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := x.Build(rootNode, idx); err != nil { + b.Fatalf("xml build failed: %v", err) + } + } +} + +func BenchmarkSecurityRequirement_Build(b *testing.B) { + rootNode := benchmarkSecurityRequirementRootNode(b) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var req SecurityRequirement + if err := req.Build(ctx, nil, rootNode, nil); err != nil { + b.Fatalf("security requirement build failed: %v", err) + } + } +} + +func BenchmarkTag_Build(b *testing.B) { + rootNode := benchmarkTagRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var tag Tag + if err := low.BuildModel(rootNode, &tag); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := tag.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("tag build failed: %v", err) + } + } +} diff --git a/datamodel/low/base/contact.go b/datamodel/low/base/contact.go index cc1d43a23..8b33c1f5f 100644 --- a/datamodel/low/base/contact.go +++ b/datamodel/low/base/contact.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -6,6 +6,7 @@ package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -26,6 +27,8 @@ type Contact struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -33,8 +36,21 @@ type Contact struct { func (c *Contact) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { c.KeyNode = keyNode c.RootNode = root - c.Reference = new(low.Reference) - c.Nodes = low.ExtractNodes(ctx, root) + c.reference = low.Reference{} + c.Reference = &c.reference + c.nodeStore = sync.Map{} + c.Nodes = &c.nodeStore + if root == nil { + c.Extensions = nil + c.context = ctx + c.index = idx + return nil + } + if len(root.Content) > 0 { + c.NodeMap.ExtractNodes(root, false) + } else { + c.AddNode(root.Line, root) + } c.Extensions = low.ExtractExtensions(root) c.context = ctx c.index = idx diff --git a/datamodel/low/base/contact_test.go b/datamodel/low/base/contact_test.go index a72b0bf9d..e3bcd3753 100644 --- a/datamodel/low/base/contact_test.go +++ b/datamodel/low/base/contact_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -44,3 +44,19 @@ x-beer: cold` assert.Nil(t, c.GetIndex()) assert.NotNil(t, c.GetContext()) } + +func TestContact_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var c Contact + err := low.BuildModel(scalar.Content[0], &c) + assert.NoError(t, err) + + err = c.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := c.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/base/example.go b/datamodel/low/base/example.go index 5b5efa143..f6b81075c 100644 --- a/datamodel/low/base/example.go +++ b/datamodel/low/base/example.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -6,6 +6,7 @@ package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -29,6 +30,8 @@ type Example struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -86,14 +89,21 @@ func (ex *Example) Hash() uint64 { // Build extracts extensions and example value func (ex *Example) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { ex.KeyNode = keyNode - ex.Reference = new(low.Reference) + ex.reference = low.Reference{} + ex.Reference = &ex.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { ex.SetReference(ref, root) } root = utils.NodeAlias(root) ex.RootNode = root utils.CheckForMergeNodes(root) - ex.Nodes = low.ExtractNodes(ctx, root) + ex.nodeStore = sync.Map{} + ex.Nodes = &ex.nodeStore + if len(root.Content) > 0 { + ex.NodeMap.ExtractNodes(root, false) + } else { + ex.AddNode(root.Line, root) + } ex.Extensions = low.ExtractExtensions(root) ex.context = ctx ex.index = idx @@ -109,14 +119,7 @@ func (ex *Example) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind ValueNode: vn, } - // extract nodes for all value nodes down the tree. - expChildNodes := low.ExtractNodesRecursive(ctx, vn) - expChildNodes.Range(func(k, v interface{}) bool { - if arr, ko := v.([]*yaml.Node); ko { - ex.Nodes.Store(k, arr) - } - return true - }) + low.MergeRecursiveNodesIfLineAbsent(ex.Nodes, vn) } // OpenAPI 3.2+ dataValue field @@ -127,14 +130,7 @@ func (ex *Example) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind ValueNode: dataVn, } - // extract nodes for all dataValue nodes down the tree. - expChildNodes := low.ExtractNodesRecursive(ctx, dataVn) - expChildNodes.Range(func(k, v interface{}) bool { - if arr, ko := v.([]*yaml.Node); ko { - ex.Nodes.Store(k, arr) - } - return true - }) + low.MergeRecursiveNodesIfLineAbsent(ex.Nodes, dataVn) } // OpenAPI 3.2+ serializedValue field diff --git a/datamodel/low/base/example_bench_test.go b/datamodel/low/base/example_bench_test.go new file mode 100644 index 000000000..9b75076b0 --- /dev/null +++ b/datamodel/low/base/example_bench_test.go @@ -0,0 +1,64 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "go.yaml.in/yaml/v4" +) + +func benchmarkExampleRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `summary: hot +description: cakes +value: + pizza: + kind: oven + toppings: + - cheese + - herbs + yummy: + yes: pizza +dataValue: + payload: + nested: + flag: true +serializedValue: '{"pizza":true}' +x-cake: + sweet: + maybe: yes` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark example: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark example: empty root") + } + return root.Content[0] +} + +func BenchmarkExample_Build(b *testing.B) { + rootNode := benchmarkExampleRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var ex Example + if err := low.BuildModel(rootNode, &ex); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := ex.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("example build failed: %v", err) + } + } +} diff --git a/datamodel/low/base/example_test.go b/datamodel/low/base/example_test.go index d88f55e71..a91b8191d 100644 --- a/datamodel/low/base/example_test.go +++ b/datamodel/low/base/example_test.go @@ -280,3 +280,17 @@ serializedValue: '{"name":"John Doe","age":30,"active":true}'` hash2 := n.Hash() assert.NotEqual(t, hash1, hash2) } + +func TestExample_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("hello"), &scalar)) + + var ex Example + require.NoError(t, low.BuildModel(scalar.Content[0], &ex)) + require.NoError(t, ex.Build(context.Background(), nil, scalar.Content[0], nil)) + + assert.NotNil(t, ex.Nodes) + nodes := ex.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/base/external_doc.go b/datamodel/low/base/external_doc.go index 534cc3712..b9054a8c6 100644 --- a/datamodel/low/base/external_doc.go +++ b/datamodel/low/base/external_doc.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -6,6 +6,7 @@ package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -28,6 +29,8 @@ type ExternalDoc struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -50,14 +53,26 @@ func (ex *ExternalDoc) GetKeyNode() *yaml.Node { // Build will extract extensions from the ExternalDoc instance. func (ex *ExternalDoc) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { ex.KeyNode = keyNode + ex.reference = low.Reference{} + ex.Reference = &ex.reference + ex.nodeStore = sync.Map{} + ex.Nodes = &ex.nodeStore + ex.context = ctx + ex.index = idx + if root == nil { + ex.RootNode = nil + ex.Extensions = nil + return nil + } root = utils.NodeAlias(root) ex.RootNode = root utils.CheckForMergeNodes(root) - ex.Reference = new(low.Reference) - ex.Nodes = low.ExtractNodes(ctx, root) + if len(root.Content) > 0 { + ex.NodeMap.ExtractNodes(root, false) + } else { + ex.AddNode(root.Line, root) + } ex.Extensions = low.ExtractExtensions(root) - ex.context = ctx - ex.index = idx return nil } diff --git a/datamodel/low/base/external_doc_test.go b/datamodel/low/base/external_doc_test.go index 16dc438d2..23fc8808a 100644 --- a/datamodel/low/base/external_doc_test.go +++ b/datamodel/low/base/external_doc_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -86,3 +86,27 @@ description: the ranch` assert.Equal(t, lDoc.Hash(), rDoc.Hash()) assert.Equal(t, 1, orderedmap.Len(lDoc.GetExtensions())) } + +func TestExternalDoc_Build_NilRoot(t *testing.T) { + var n ExternalDoc + err := n.Build(context.Background(), nil, nil, nil) + assert.NoError(t, err) + assert.Nil(t, n.GetRootNode()) + assert.Nil(t, n.GetExtensions()) +} + +func TestExternalDoc_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n ExternalDoc + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/base/info.go b/datamodel/low/base/info.go index 4de8cd4e2..8cb2dc58f 100644 --- a/datamodel/low/base/info.go +++ b/datamodel/low/base/info.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -6,6 +6,7 @@ package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" @@ -35,6 +36,8 @@ type Info struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -62,14 +65,26 @@ func (i *Info) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Val // Build will extract out the Contact and Info objects from the supplied root node. func (i *Info) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { i.KeyNode = keyNode + i.reference = low.Reference{} + i.Reference = &i.reference + i.nodeStore = sync.Map{} + i.Nodes = &i.nodeStore + i.index = idx + i.context = ctx + if root == nil { + i.RootNode = nil + i.Extensions = nil + return nil + } root = utils.NodeAlias(root) i.RootNode = root utils.CheckForMergeNodes(root) - i.Reference = new(low.Reference) - i.Nodes = low.ExtractNodes(ctx, root) + if len(root.Content) > 0 { + i.NodeMap.ExtractNodes(root, false) + } else { + i.AddNode(root.Line, root) + } i.Extensions = low.ExtractExtensions(root) - i.index = idx - i.context = ctx // extract contact contact, _ := low.ExtractObject[*Contact](ctx, ContactLabel, root, idx) diff --git a/datamodel/low/base/info_test.go b/datamodel/low/base/info_test.go index 3aa00022b..44a3fac03 100644 --- a/datamodel/low/base/info_test.go +++ b/datamodel/low/base/info_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -78,6 +78,30 @@ func TestLicense_Build(t *testing.T) { assert.Nil(t, k) } +func TestInfo_Build_NilRoot(t *testing.T) { + var n Info + err := n.Build(context.Background(), nil, nil, nil) + assert.NoError(t, err) + assert.Nil(t, n.GetRootNode()) + assert.Nil(t, n.GetExtensions()) +} + +func TestInfo_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n Info + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + func TestInfo_Hash(t *testing.T) { left := `title: princess b33f summary: a thing diff --git a/datamodel/low/base/license.go b/datamodel/low/base/license.go index 887dea59d..c6ec47a19 100644 --- a/datamodel/low/base/license.go +++ b/datamodel/low/base/license.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -6,6 +6,7 @@ package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -27,6 +28,8 @@ type License struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -34,15 +37,26 @@ type License struct { // Build out a license, complain if both a URL and identifier are present as they are mutually exclusive func (l *License) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { l.KeyNode = keyNode + l.reference = low.Reference{} + l.Reference = &l.reference + l.nodeStore = sync.Map{} + l.Nodes = &l.nodeStore + l.context = ctx + l.index = idx + if root == nil { + l.RootNode = nil + l.Extensions = nil + return nil + } root = utils.NodeAlias(root) l.RootNode = root utils.CheckForMergeNodes(root) - l.Reference = new(low.Reference) - no := low.ExtractNodes(ctx, root) + if len(root.Content) > 0 { + l.NodeMap.ExtractNodes(root, false) + } else { + l.AddNode(root.Line, root) + } l.Extensions = low.ExtractExtensions(root) - l.Nodes = no - l.context = ctx - l.index = idx return nil } diff --git a/datamodel/low/base/license_test.go b/datamodel/low/base/license_test.go index afb676362..869ab166b 100644 --- a/datamodel/low/base/license_test.go +++ b/datamodel/low/base/license_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -64,3 +64,19 @@ description: the ranch` assert.Equal(t, lDoc.Hash(), rDoc.Hash()) } + +func TestLicense_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var l License + err := low.BuildModel(scalar.Content[0], &l) + assert.NoError(t, err) + + err = l.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := l.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index b56743245..6f81c0171 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -4,10 +4,6 @@ import ( "context" "errors" "fmt" - "hash/maphash" - "sort" - "strconv" - "strings" "sync" "github.com/pb33f/libopenapi/datamodel/low" @@ -15,7 +11,6 @@ import ( "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "go.yaml.in/yaml/v4" - "golang.org/x/sync/errgroup" ) // SchemaDynamicValue is used to hold multiple possible types for a schema property. There are two values, a left @@ -45,18 +40,6 @@ func (s *SchemaDynamicValue[A, B]) IsB() bool { return s.N == 1 } -// Hash will generate a stable hash of the SchemaDynamicValue -func (s *SchemaDynamicValue[A, B]) Hash() uint64 { - return low.WithHasher(func(h *maphash.Hash) uint64 { - if s.IsA() { - h.WriteString(low.GenerateHashString(s.A)) - } else { - h.WriteString(low.GenerateHashString(s.B)) - } - return h.Sum64() - }) -} - // Schema represents a JSON Schema that support Swagger, OpenAPI 3 and OpenAPI 3.1 // // Until 3.1 OpenAPI had a strange relationship with JSON Schema. It's been a super-set/sub-set @@ -156,1460 +139,16 @@ type Schema struct { ParentProxy *SchemaProxy // Index is a reference to the SpecIndex that was used to build this schema. - Index *index.SpecIndex - RootNode *yaml.Node - index *index.SpecIndex - context context.Context + Index *index.SpecIndex + RootNode *yaml.Node + index *index.SpecIndex + context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } -// GetIndex will return the index.SpecIndex instance attached to the Schema object -func (s *Schema) GetIndex() *index.SpecIndex { - return s.index -} - -// GetContext will return the context.Context instance used when building the Schema object -func (s *Schema) GetContext() context.Context { - return s.context -} - -// QuickHash will calculate a hash from the values of the schema, however the hash is not very deep -// and is used for quick equality checking, This method exists because a full hash could end up churning through -// thousands of polymorphic references. With a quick hash, polymorphic properties are not included. -func (s *Schema) QuickHash() uint64 { - return s.hash(true) -} - -// Hash will calculate a hash from the values of the schema, This allows equality checking against -// Schemas defined inside an OpenAPI document. The only way to know if a schema has changed, is to hash it. -func (s *Schema) Hash() uint64 { - return s.hash(false) -} - -// SchemaQuickHashMap is a sync.Map used to store quick hashes of schemas, used by quick hashing to prevent -// over rotation on the same schema. This map is automatically reset each time `CompareDocuments` is called by the -// `what-changed` package and each time a model is built via `BuildV3Model()` etc. -// -// This exists because to ensure deep equality checking when composing schemas using references. However this -// can cause an exhaustive deep hash calculation that chews up compute like crazy, particularly with polymorphic refs. -// The hash map means each schema is hashed once, and then the hash is reused for quick equality checking. -var SchemaQuickHashMap sync.Map - -// ClearSchemaQuickHashMap resets the schema quick-hash cache. -// Call this between document lifecycles in long-running processes to bound memory. -func ClearSchemaQuickHashMap() { - SchemaQuickHashMap.Clear() -} - -func (s *Schema) hash(quick bool) uint64 { - if s == nil { - return 0 - } - - // create a key for the schema, this is used to quickly check if the schema has been hashed before, and prevent re-hashing. - idx := s.GetIndex() - path := "" - if idx != nil { - path = idx.GetSpecAbsolutePath() - } - cfId := "root" - if s.Index != nil { - if s.Index.GetRolodex() != nil { - if s.Index.GetRolodex().GetId() != "" { - cfId = s.Index.GetRolodex().GetId() - } - } else { - cfId = s.Index.GetConfig().GetId() - } - } - var keyBuf strings.Builder - keyBuf.Grow(len(path) + len(cfId) + 16) - keyBuf.WriteString(path) - keyBuf.WriteByte(':') - keyBuf.WriteString(strconv.Itoa(s.RootNode.Line)) - keyBuf.WriteByte(':') - keyBuf.WriteString(strconv.Itoa(s.RootNode.Column)) - keyBuf.WriteByte(':') - keyBuf.WriteString(cfId) - key := keyBuf.String() - if quick { - if v, ok := SchemaQuickHashMap.Load(key); ok { - if r, k := v.(uint64); k { - return r - } - } - } - - // Use string builder pool for efficient string concatenation - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - // calculate a hash from every property in the schema. - if !s.SchemaTypeRef.IsEmpty() { - sb.WriteString(s.SchemaTypeRef.Value) - sb.WriteByte('|') - } - if !s.Title.IsEmpty() { - sb.WriteString(s.Title.Value) - sb.WriteByte('|') - } - if !s.MultipleOf.IsEmpty() { - sb.WriteString(strconv.FormatFloat(s.MultipleOf.Value, 'g', -1, 64)) - sb.WriteByte('|') - } - if !s.Maximum.IsEmpty() { - sb.WriteString(strconv.FormatFloat(s.Maximum.Value, 'g', -1, 64)) - sb.WriteByte('|') - } - if !s.Minimum.IsEmpty() { - sb.WriteString(strconv.FormatFloat(s.Minimum.Value, 'g', -1, 64)) - sb.WriteByte('|') - } - if !s.MaxLength.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MaxLength.Value, 10)) - sb.WriteByte('|') - } - if !s.MinLength.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MinLength.Value, 10)) - sb.WriteByte('|') - } - if !s.Pattern.IsEmpty() { - sb.WriteString(s.Pattern.Value) - sb.WriteByte('|') - } - if !s.Format.IsEmpty() { - sb.WriteString(s.Format.Value) - sb.WriteByte('|') - } - if !s.MaxItems.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MaxItems.Value, 10)) - sb.WriteByte('|') - } - if !s.MinItems.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MinItems.Value, 10)) - sb.WriteByte('|') - } - if !s.UniqueItems.IsEmpty() { - sb.WriteString(strconv.FormatBool(s.UniqueItems.Value)) - sb.WriteByte('|') - } - if !s.MaxProperties.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MaxProperties.Value, 10)) - sb.WriteByte('|') - } - if !s.MinProperties.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MinProperties.Value, 10)) - sb.WriteByte('|') - } - if !s.AdditionalProperties.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.AdditionalProperties.Value)) - sb.WriteByte('|') - } - if !s.Description.IsEmpty() { - sb.WriteString(s.Description.Value) - sb.WriteByte('|') - } - if !s.ContentEncoding.IsEmpty() { - sb.WriteString(s.ContentEncoding.Value) - sb.WriteByte('|') - } - if !s.ContentMediaType.IsEmpty() { - sb.WriteString(s.ContentMediaType.Value) - sb.WriteByte('|') - } - if !s.Default.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Default.Value)) - sb.WriteByte('|') - } - if !s.Const.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Const.Value)) - sb.WriteByte('|') - } - if !s.Nullable.IsEmpty() { - sb.WriteString(strconv.FormatBool(s.Nullable.Value)) - sb.WriteByte('|') - } - if !s.ReadOnly.IsEmpty() { - sb.WriteString(strconv.FormatBool(s.ReadOnly.Value)) - sb.WriteByte('|') - } - if !s.WriteOnly.IsEmpty() { - sb.WriteString(strconv.FormatBool(s.WriteOnly.Value)) - sb.WriteByte('|') - } - if !s.Deprecated.IsEmpty() { - sb.WriteString(strconv.FormatBool(s.Deprecated.Value)) - sb.WriteByte('|') - } - if !s.ExclusiveMaximum.IsEmpty() && s.ExclusiveMaximum.Value.IsA() { - sb.WriteString(strconv.FormatBool(s.ExclusiveMaximum.Value.A)) - sb.WriteByte('|') - } - if !s.ExclusiveMaximum.IsEmpty() && s.ExclusiveMaximum.Value.IsB() { - sb.WriteString(strconv.FormatFloat(s.ExclusiveMaximum.Value.B, 'g', -1, 64)) - sb.WriteByte('|') - } - if !s.ExclusiveMinimum.IsEmpty() && s.ExclusiveMinimum.Value.IsA() { - sb.WriteString(strconv.FormatBool(s.ExclusiveMinimum.Value.A)) - sb.WriteByte('|') - } - if !s.ExclusiveMinimum.IsEmpty() && s.ExclusiveMinimum.Value.IsB() { - sb.WriteString(strconv.FormatFloat(s.ExclusiveMinimum.Value.B, 'g', -1, 64)) - sb.WriteByte('|') - } - if !s.Type.IsEmpty() && s.Type.Value.IsA() { - sb.WriteString(s.Type.Value.A) - sb.WriteByte('|') - } - if !s.Type.IsEmpty() && s.Type.Value.IsB() { - // Pre-allocate slice for Type.B values - j := make([]string, len(s.Type.Value.B)) - for h := range s.Type.Value.B { - j[h] = s.Type.Value.B[h].Value - } - sort.Strings(j) - for _, val := range j { - sb.WriteString(val) - } - sb.WriteByte('|') - } - - // Process Required values - if len(s.Required.Value) > 0 { - keys := make([]string, len(s.Required.Value)) - for i := range s.Required.Value { - keys[i] = s.Required.Value[i].Value - } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - // Process Enum values - if len(s.Enum.Value) > 0 { - keys := make([]string, len(s.Enum.Value)) - for i := range s.Enum.Value { - keys[i] = low.ValueToString(s.Enum.Value[i].Value) - } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - // Append map hashes using helper function - for _, hash := range low.AppendMapHashes(nil, s.Properties.Value) { - sb.WriteString(hash) - sb.WriteByte('|') - } - - if s.XML.Value != nil { - sb.WriteString(low.GenerateHashString(s.XML.Value)) - sb.WriteByte('|') - } - if s.ExternalDocs.Value != nil { - sb.WriteString(low.GenerateHashString(s.ExternalDocs.Value)) - sb.WriteByte('|') - } - if s.Discriminator.Value != nil { - sb.WriteString(low.GenerateHashString(s.Discriminator.Value)) - sb.WriteByte('|') - } - - // hash polymorphic data - OneOf - if len(s.OneOf.Value) > 0 { - oneOfKeys := make([]string, len(s.OneOf.Value)) - for i := range s.OneOf.Value { - oneOfKeys[i] = low.GenerateHashString(s.OneOf.Value[i].Value) - } - sort.Strings(oneOfKeys) - for _, key := range oneOfKeys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - // hash polymorphic data - AllOf - if len(s.AllOf.Value) > 0 { - allOfKeys := make([]string, len(s.AllOf.Value)) - for i := range s.AllOf.Value { - allOfKeys[i] = low.GenerateHashString(s.AllOf.Value[i].Value) - } - sort.Strings(allOfKeys) - for _, key := range allOfKeys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - // hash polymorphic data - AnyOf - if len(s.AnyOf.Value) > 0 { - anyOfKeys := make([]string, len(s.AnyOf.Value)) - for i := range s.AnyOf.Value { - anyOfKeys[i] = low.GenerateHashString(s.AnyOf.Value[i].Value) - } - sort.Strings(anyOfKeys) - for _, key := range anyOfKeys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - if !s.Not.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Not.Value)) - sb.WriteByte('|') - } - - // check if items is a schema or a bool. - if !s.Items.IsEmpty() && s.Items.Value.IsA() { - sb.WriteString(low.GenerateHashString(s.Items.Value.A)) - sb.WriteByte('|') - } - if !s.Items.IsEmpty() && s.Items.Value.IsB() { - sb.WriteString(strconv.FormatBool(s.Items.Value.B)) - sb.WriteByte('|') - } - // 3.1 only props - if !s.If.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.If.Value)) - sb.WriteByte('|') - } - if !s.Else.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Else.Value)) - sb.WriteByte('|') - } - if !s.Then.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Then.Value)) - sb.WriteByte('|') - } - if !s.PropertyNames.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.PropertyNames.Value)) - sb.WriteByte('|') - } - if !s.UnevaluatedProperties.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.UnevaluatedProperties.Value)) - sb.WriteByte('|') - } - if !s.UnevaluatedItems.IsEmpty() { - 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('|') - } - if !s.DynamicAnchor.IsEmpty() { - sb.WriteString(s.DynamicAnchor.Value) - sb.WriteByte('|') - } - if !s.DynamicRef.IsEmpty() { - sb.WriteString(s.DynamicRef.Value) - sb.WriteByte('|') - } - if !s.Comment.IsEmpty() { - sb.WriteString(s.Comment.Value) - sb.WriteByte('|') - } - if !s.ContentSchema.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.ContentSchema.Value)) - sb.WriteByte('|') - } - if s.Vocabulary.Value != nil { - // sort vocabulary keys for deterministic hashing - // pre-allocate with known size for better memory efficiency - vocabSize := orderedmap.Len(s.Vocabulary.Value) - vocabKeys := make([]string, 0, vocabSize) - vocabMap := make(map[string]bool, vocabSize) - for k, v := range s.Vocabulary.Value.FromOldest() { - vocabKeys = append(vocabKeys, k.Value) - vocabMap[k.Value] = v.Value - } - sort.Strings(vocabKeys) - for _, k := range vocabKeys { - sb.WriteString(k) - sb.WriteByte(':') - sb.WriteString(strconv.FormatBool(vocabMap[k])) - sb.WriteByte('|') - } - } - - // Process dependent schemas and pattern properties - for _, hash := range low.AppendMapHashes(nil, orderedmap.SortAlpha(s.DependentSchemas.Value)) { - sb.WriteString(hash) - sb.WriteByte('|') - } - - // Process dependent required - if s.DependentRequired.Value != nil { - // Sort keys for deterministic hashing - var depReqKeys []string - depReqMap := make(map[string][]string) - for prop, requiredProps := range s.DependentRequired.Value.FromOldest() { - depReqKeys = append(depReqKeys, prop.Value) - depReqMap[prop.Value] = requiredProps.Value - } - sort.Strings(depReqKeys) - - for _, prop := range depReqKeys { - sb.WriteString(prop) - sb.WriteByte(':') - requiredProps := depReqMap[prop] - for i, reqProp := range requiredProps { - sb.WriteString(reqProp) - if i < len(requiredProps)-1 { - sb.WriteByte(',') - } - } - sb.WriteByte('|') - } - } - - for _, hash := range low.AppendMapHashes(nil, orderedmap.SortAlpha(s.PatternProperties.Value)) { - sb.WriteString(hash) - sb.WriteByte('|') - } - - // Process PrefixItems - if len(s.PrefixItems.Value) > 0 { - itemsKeys := make([]string, len(s.PrefixItems.Value)) - for i := range s.PrefixItems.Value { - itemsKeys[i] = low.GenerateHashString(s.PrefixItems.Value[i].Value) - } - sort.Strings(itemsKeys) - for _, key := range itemsKeys { - sb.WriteString(key) - sb.WriteByte('|') - } - } - - // Process extensions - for _, ext := range low.HashExtensions(s.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - - if s.Example.Value != nil { - sb.WriteString(low.GenerateHashString(s.Example.Value)) - sb.WriteByte('|') - } - - // contains - if !s.Contains.IsEmpty() { - sb.WriteString(low.GenerateHashString(s.Contains.Value)) - sb.WriteByte('|') - } - if !s.MinContains.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MinContains.Value, 10)) - sb.WriteByte('|') - } - if !s.MaxContains.IsEmpty() { - sb.WriteString(strconv.FormatInt(s.MaxContains.Value, 10)) - sb.WriteByte('|') - } - if !s.Examples.IsEmpty() { - for _, ex := range s.Examples.Value { - sb.WriteString(low.GenerateHashString(ex.Value)) - sb.WriteByte('|') - } - } - - h := low.WithHasher(func(hasher *maphash.Hash) uint64 { - hasher.WriteString(sb.String()) - return hasher.Sum64() - }) - SchemaQuickHashMap.Store(key, h) - return h -} - -// FindProperty will return a ValueReference pointer containing a SchemaProxy pointer -// from a property key name. if found -func (s *Schema) FindProperty(name string) *low.ValueReference[*SchemaProxy] { - return low.FindItemInOrderedMap[*SchemaProxy](name, s.Properties.Value) -} - -// FindDependentSchema will return a ValueReference pointer containing a SchemaProxy pointer -// from a dependent schema key name. if found (3.1+ only) -func (s *Schema) FindDependentSchema(name string) *low.ValueReference[*SchemaProxy] { - return low.FindItemInOrderedMap[*SchemaProxy](name, s.DependentSchemas.Value) -} - -// FindPatternProperty will return a ValueReference pointer containing a SchemaProxy pointer -// from a pattern property key name. if found (3.1+ only) -func (s *Schema) FindPatternProperty(name string) *low.ValueReference[*SchemaProxy] { - return low.FindItemInOrderedMap[*SchemaProxy](name, s.PatternProperties.Value) -} - -// GetExtensions returns all extensions for Schema -func (s *Schema) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { - return s.Extensions -} - -// GetRootNode will return the root yaml node of the Schema object -func (s *Schema) GetRootNode() *yaml.Node { - return s.RootNode -} - -// Build will perform a number of operations. -// Extraction of the following happens in this method: -// - Extensions -// - Type -// - ExclusiveMinimum and ExclusiveMaximum -// - Examples -// - AdditionalProperties -// - Discriminator -// - ExternalDocs -// - XML -// - Properties -// - AllOf, OneOf, AnyOf -// - Not -// - Items -// - PrefixItems -// - If -// - Else -// - Then -// - DependentSchemas -// - PatternProperties -// - PropertyNames -// - UnevaluatedItems -// - UnevaluatedProperties -// - Anchor -func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) error { - if root == nil { - return fmt.Errorf("cannot build schema from a nil node") - } - root = utils.NodeAlias(root) - utils.CheckForMergeNodes(root) - - // Note: sibling ref transformation now happens in SchemaProxy.Build() - // so the root node should already be pre-transformed if needed - - s.Reference = new(low.Reference) - no := low.ExtractNodes(ctx, root) - s.Nodes = no - s.Index = idx - s.RootNode = root - s.context = ctx - s.index = idx - - // check if this schema was transformed from a sibling ref - // if so, skip reference dereferencing to preserve the allOf structure - isTransformed := false - if s.ParentProxy != nil && s.ParentProxy.TransformedRef != nil { - isTransformed = true - } - - if !isTransformed { - if h, _, _ := utils.IsNodeRefValue(root); h { - ref, _, err, fctx := low.LocateRefNodeWithContext(ctx, root, idx) - if ref != nil { - root = ref - if fctx != nil { - ctx = fctx - } - if err != nil { - if !idx.AllowCircularReferenceResolving() { - return fmt.Errorf("build schema failed: %s", err.Error()) - } - } - } else { - return fmt.Errorf("build schema failed: reference cannot be found: '%s', line %d, col %d", - root.Content[1].Value, root.Content[1].Line, root.Content[1].Column) - } - } - } - - // Build model using possibly dereferenced root - if err := low.BuildModel(root, s); err != nil { - return err - } - - s.extractExtensions(root) - - // if the schema has required values, extract the nodes for them. - if s.Required.Value != nil { - for _, r := range s.Required.Value { - s.AddNode(r.ValueNode.Line, r.ValueNode) - } - } - - // same thing with enums - if s.Enum.Value != nil { - for _, e := range s.Enum.Value { - s.AddNode(e.ValueNode.Line, e.ValueNode) - } - } - - // determine schema type, singular (3.0) or multiple (3.1), use a variable value - _, typeLabel, typeValue := utils.FindKeyNodeFullTop(TypeLabel, root.Content) - if typeValue != nil { - if utils.IsNodeStringValue(typeValue) { - s.Type = low.NodeReference[SchemaDynamicValue[string, []low.ValueReference[string]]]{ - KeyNode: typeLabel, - ValueNode: typeValue, - Value: SchemaDynamicValue[string, []low.ValueReference[string]]{N: 0, A: typeValue.Value}, - } - } - if utils.IsNodeArray(typeValue) { - - var refs []low.ValueReference[string] - for r := range typeValue.Content { - refs = append(refs, low.ValueReference[string]{ - Value: typeValue.Content[r].Value, - ValueNode: typeValue.Content[r], - }) - } - s.Type = low.NodeReference[SchemaDynamicValue[string, []low.ValueReference[string]]]{ - KeyNode: typeLabel, - ValueNode: typeValue, - Value: SchemaDynamicValue[string, []low.ValueReference[string]]{N: 1, B: refs}, - } - } - } - - // determine exclusive minimum type, bool (3.0) or int (3.1) - _, exMinLabel, exMinValue := utils.FindKeyNodeFullTop(ExclusiveMinimumLabel, root.Content) - if exMinValue != nil { - // if there is an index, determine if this a 3.0 or 3.1+ schema - if idx != nil { - if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { - val, _ := strconv.ParseFloat(exMinValue.Value, 64) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { - val, _ := strconv.ParseBool(exMinValue.Value) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - } else { - - // there is no index, so we have to determine the type based on the value - if utils.IsNodeBoolValue(exMinValue) { - val, _ := strconv.ParseBool(exMinValue.Value) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - if utils.IsNodeIntValue(exMinValue) { - val, _ := strconv.ParseFloat(exMinValue.Value, 64) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - } - } - - // determine exclusive maximum type, bool (3.0) or int (3.1+) - _, exMaxLabel, exMaxValue := utils.FindKeyNodeFullTop(ExclusiveMaximumLabel, root.Content) - if exMaxValue != nil { - // if there is an index, determine if this a 3.0 or 3.1+ schema - if idx != nil { - if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { - val, _ := strconv.ParseFloat(exMaxValue.Value, 64) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { - val, _ := strconv.ParseBool(exMaxValue.Value) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - } else { - - // there is no index, so we have to determine the type based on the value - if utils.IsNodeBoolValue(exMaxValue) { - val, _ := strconv.ParseBool(exMaxValue.Value) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - if utils.IsNodeIntValue(exMaxValue) { - val, _ := strconv.ParseFloat(exMaxValue.Value, 64) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - } - } - - // handle schema reference type if set. (3.1) - _, schemaRefLabel, schemaRefNode := utils.FindKeyNodeFullTop(SchemaTypeLabel, root.Content) - if schemaRefNode != nil { - s.SchemaTypeRef = low.NodeReference[string]{ - Value: schemaRefNode.Value, KeyNode: schemaRefLabel, ValueNode: schemaRefNode, - } - } - - // 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 { - s.Anchor = low.NodeReference[string]{ - Value: anchorNode.Value, KeyNode: anchorLabel, ValueNode: anchorNode, - } - } - - // handle $dynamicAnchor if set. (3.1+, JSON Schema 2020-12) - _, dynamicAnchorLabel, dynamicAnchorNode := utils.FindKeyNodeFullTop(DynamicAnchorLabel, root.Content) - if dynamicAnchorNode != nil { - s.DynamicAnchor = low.NodeReference[string]{ - Value: dynamicAnchorNode.Value, KeyNode: dynamicAnchorLabel, ValueNode: dynamicAnchorNode, - } - } - - // handle $dynamicRef if set. (3.1+, JSON Schema 2020-12) - _, dynamicRefLabel, dynamicRefNode := utils.FindKeyNodeFullTop(DynamicRefLabel, root.Content) - if dynamicRefNode != nil { - s.DynamicRef = low.NodeReference[string]{ - Value: dynamicRefNode.Value, KeyNode: dynamicRefLabel, ValueNode: dynamicRefNode, - } - } - - // handle $comment if set. (JSON Schema 2020-12) - _, commentLabel, commentNode := utils.FindKeyNodeFullTop(CommentLabel, root.Content) - if commentNode != nil { - s.Comment = low.NodeReference[string]{ - Value: commentNode.Value, KeyNode: commentLabel, ValueNode: commentNode, - } - } - - // handle $vocabulary if set. (JSON Schema 2020-12 - typically in meta-schemas) - _, vocabLabel, vocabNode := utils.FindKeyNodeFullTop(VocabularyLabel, root.Content) - if vocabNode != nil && utils.IsNodeMap(vocabNode) { - vocabularyMap := orderedmap.New[low.KeyReference[string], low.ValueReference[bool]]() - var currentKey *yaml.Node - for i, node := range vocabNode.Content { - if i%2 == 0 { - currentKey = node - continue - } - // use strconv.ParseBool for robust boolean parsing (handles "true", "false", "1", "0", etc.) - boolVal, _ := strconv.ParseBool(node.Value) - vocabularyMap.Set(low.KeyReference[string]{ - KeyNode: currentKey, - Value: currentKey.Value, - }, low.ValueReference[bool]{ - Value: boolVal, - ValueNode: node, - }) - } - s.Vocabulary = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]]{ - Value: vocabularyMap, - KeyNode: vocabLabel, - ValueNode: vocabNode, - } - } - - // handle example if set. (3.0) - _, expLabel, expNode := utils.FindKeyNodeFullTop(ExampleLabel, root.Content) - if expNode != nil { - s.Example = low.NodeReference[*yaml.Node]{Value: expNode, KeyNode: expLabel, ValueNode: expNode} - - // extract nodes for all value nodes down the tree. - expChildNodes := low.ExtractNodesRecursive(ctx, expNode) - // map to the local schema - expChildNodes.Range(func(k, v interface{}) bool { - if arr, ko := v.([]*yaml.Node); ko { - if _, ok := s.Nodes.Load(k); !ok { - s.Nodes.Store(k, arr) - } - } - return true - }) - } - - // handle examples if set.(3.1) - _, expArrLabel, expArrNode := utils.FindKeyNodeFullTop(ExamplesLabel, root.Content) - if expArrNode != nil { - if utils.IsNodeArray(expArrNode) { - var examples []low.ValueReference[*yaml.Node] - for i := range expArrNode.Content { - examples = append(examples, low.ValueReference[*yaml.Node]{Value: expArrNode.Content[i], ValueNode: expArrNode.Content[i]}) - } - s.Examples = low.NodeReference[[]low.ValueReference[*yaml.Node]]{ - Value: examples, - ValueNode: expArrNode, - KeyNode: expArrLabel, - } - // extract nodes for all value nodes down the tree. - expChildNodes := low.ExtractNodesRecursive(ctx, expArrNode) - // map to the local schema - expChildNodes.Range(func(k, v interface{}) bool { - if arr, ko := v.([]*yaml.Node); ko { - if _, ok := s.Nodes.Load(k); !ok { - s.Nodes.Store(k, arr) - } - } - return true - }) - } - } - - // check additionalProperties type for schema or bool - addPropsIsBool := false - addPropsBoolValue := true - _, addPLabel, addPValue := utils.FindKeyNodeFullTop(AdditionalPropertiesLabel, root.Content) - if addPValue != nil { - if utils.IsNodeBoolValue(addPValue) { - addPropsIsBool = true - addPropsBoolValue, _ = strconv.ParseBool(addPValue.Value) - } - } - if addPropsIsBool { - s.AdditionalProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - B: addPropsBoolValue, - N: 1, - }, - KeyNode: addPLabel, - ValueNode: addPValue, - } - } - - // handle discriminator if set. - _, discLabel, discNode := utils.FindKeyNodeFullTop(DiscriminatorLabel, root.Content) - if discNode != nil { - var discriminator Discriminator - _ = low.BuildModel(discNode, &discriminator) - discriminator.KeyNode = discLabel - discriminator.RootNode = discNode - discriminator.Nodes = low.ExtractNodes(ctx, discNode) - s.Discriminator = low.NodeReference[*Discriminator]{Value: &discriminator, KeyNode: discLabel, ValueNode: discNode} - // add discriminator nodes, because there is no build method. - dn := low.ExtractNodesRecursive(ctx, discNode) - dn.Range(func(key, val any) bool { - if n, ok := val.([]*yaml.Node); ok { - for _, g := range n { - discriminator.AddNode(key.(int), g) - } - } - return true - }) - } - - // handle externalDocs if set. - _, extDocLabel, extDocNode := utils.FindKeyNodeFullTop(ExternalDocsLabel, root.Content) - if extDocNode != nil { - var exDoc ExternalDoc - _ = low.BuildModel(extDocNode, &exDoc) - _ = exDoc.Build(ctx, extDocLabel, extDocNode, idx) // throws no errors, can't check for one. - exDoc.Nodes = low.ExtractNodes(ctx, extDocNode) - s.ExternalDocs = low.NodeReference[*ExternalDoc]{Value: &exDoc, KeyNode: extDocLabel, ValueNode: extDocNode} - } - - // handle xml if set. - _, xmlLabel, xmlNode := utils.FindKeyNodeFullTop(XMLLabel, root.Content) - if xmlNode != nil { - var xml XML - _ = low.BuildModel(xmlNode, &xml) - // extract extensions if set. - _ = xml.Build(xmlNode, idx) // returns no errors, can't check for one. - xml.Nodes = low.ExtractNodes(ctx, xmlNode) - s.XML = low.NodeReference[*XML]{Value: &xml, KeyNode: xmlLabel, ValueNode: xmlNode} - } - - // handle properties - props, err := buildPropertyMap(ctx, s, root, idx, PropertiesLabel) - if err != nil { - return err - } - if props != nil { - s.Properties = *props - } - - // handle dependent schemas - props, err = buildPropertyMap(ctx, s, root, idx, DependentSchemasLabel) - if err != nil { - return err - } - if props != nil { - s.DependentSchemas = *props - } - - // handle dependent required - depReq, err := buildDependentRequiredMap(root, DependentRequiredLabel) - if err != nil { - return err - } - if depReq != nil { - s.DependentRequired = *depReq - } - - // handle pattern properties - props, err = buildPropertyMap(ctx, s, root, idx, PatternPropertiesLabel) - if err != nil { - return err - } - if props != nil { - s.PatternProperties = *props - } - - // check items type for schema or bool (3.1 only) - itemsIsBool := false - itemsBoolValue := false - _, itemsLabel, itemsValue := utils.FindKeyNodeFullTop(ItemsLabel, root.Content) - if itemsValue != nil { - if utils.IsNodeBoolValue(itemsValue) { - itemsIsBool = true - itemsBoolValue, _ = strconv.ParseBool(itemsValue.Value) - } - } - if itemsIsBool { - s.Items = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - B: itemsBoolValue, - N: 1, - }, - KeyNode: itemsLabel, - ValueNode: itemsValue, - } - } - - // check unevaluatedProperties type for schema or bool (3.1 only) - unevalIsBool := false - unevalBoolValue := true - _, unevalLabel, unevalValue := utils.FindKeyNodeFullTop(UnevaluatedPropertiesLabel, root.Content) - if unevalValue != nil { - if utils.IsNodeBoolValue(unevalValue) { - unevalIsBool = true - unevalBoolValue, _ = strconv.ParseBool(unevalValue.Value) - } - } - if unevalIsBool { - s.UnevaluatedProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - B: unevalBoolValue, - N: 1, - }, - KeyNode: unevalLabel, - ValueNode: unevalValue, - } - } - - var allOf, anyOf, oneOf, prefixItems []low.ValueReference[*SchemaProxy] - var items, not, contains, sif, selse, sthen, propertyNames, unevalItems, unevalProperties, addProperties, contentSch low.ValueReference[*SchemaProxy] - - _, allOfLabel, allOfValue := utils.FindKeyNodeFullTop(AllOfLabel, root.Content) - _, anyOfLabel, anyOfValue := utils.FindKeyNodeFullTop(AnyOfLabel, root.Content) - _, oneOfLabel, oneOfValue := utils.FindKeyNodeFullTop(OneOfLabel, root.Content) - _, notLabel, notValue := utils.FindKeyNodeFullTop(NotLabel, root.Content) - _, prefixItemsLabel, prefixItemsValue := utils.FindKeyNodeFullTop(PrefixItemsLabel, root.Content) - _, containsLabel, containsValue := utils.FindKeyNodeFullTop(ContainsLabel, root.Content) - _, sifLabel, sifValue := utils.FindKeyNodeFullTop(IfLabel, root.Content) - _, selseLabel, selseValue := utils.FindKeyNodeFullTop(ElseLabel, root.Content) - _, sthenLabel, sthenValue := utils.FindKeyNodeFullTop(ThenLabel, root.Content) - _, propNamesLabel, propNamesValue := utils.FindKeyNodeFullTop(PropertyNamesLabel, root.Content) - _, unevalItemsLabel, unevalItemsValue := utils.FindKeyNodeFullTop(UnevaluatedItemsLabel, root.Content) - // Reuse earlier lookups for unevaluatedProperties and additionalProperties instead of re-scanning. - unevalPropsLabel, unevalPropsValue := unevalLabel, unevalValue - addPropsLabel, addPropsValue := addPLabel, addPValue - _, contentSchLabel, contentSchValue := utils.FindKeyNodeFullTop(ContentSchemaLabel, root.Content) - - if ctx == nil { - ctx = context.Background() - } - - g, gCtx := errgroup.WithContext(ctx) - - if allOfValue != nil { - g.Go(func() error { - res, err := buildSchemaList(gCtx, allOfLabel, allOfValue, idx) - if err == nil { - allOf = res - } - return err - }) - } - if anyOfValue != nil { - g.Go(func() error { - res, err := buildSchemaList(gCtx, anyOfLabel, anyOfValue, idx) - if err == nil { - anyOf = res - } - return err - }) - } - if oneOfValue != nil { - g.Go(func() error { - res, err := buildSchemaList(gCtx, oneOfLabel, oneOfValue, idx) - if err == nil { - oneOf = res - } - return err - }) - } - if prefixItemsValue != nil { - g.Go(func() error { - res, err := buildSchemaList(gCtx, prefixItemsLabel, prefixItemsValue, idx) - if err == nil { - prefixItems = res - } - return err - }) - } - if notValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, notLabel, notValue, idx) - if err == nil { - not = res - } - return err - }) - } - if containsValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, containsLabel, containsValue, idx) - if err == nil { - contains = res - } - return err - }) - } - if !itemsIsBool && itemsValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, itemsLabel, itemsValue, idx) - if err == nil { - items = res - } - return err - }) - } - if sifValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, sifLabel, sifValue, idx) - if err == nil { - sif = res - } - return err - }) - } - if selseValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, selseLabel, selseValue, idx) - if err == nil { - selse = res - } - return err - }) - } - if sthenValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, sthenLabel, sthenValue, idx) - if err == nil { - sthen = res - } - return err - }) - } - if propNamesValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, propNamesLabel, propNamesValue, idx) - if err == nil { - propertyNames = res - } - return err - }) - } - if unevalItemsValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, unevalItemsLabel, unevalItemsValue, idx) - if err == nil { - unevalItems = res - } - return err - }) - } - if !unevalIsBool && unevalPropsValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, unevalPropsLabel, unevalPropsValue, idx) - if err == nil { - unevalProperties = res - } - return err - }) - } - if !addPropsIsBool && addPropsValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, addPropsLabel, addPropsValue, idx) - if err == nil { - addProperties = res - } - return err - }) - } - if contentSchValue != nil { - g.Go(func() error { - res, err := buildSchema(gCtx, contentSchLabel, contentSchValue, idx) - if err == nil { - contentSch = res - } - return err - }) - } - - if err := g.Wait(); err != nil { - return fmt.Errorf("failed to build schema: %w", err) - } - - if len(anyOf) > 0 { - s.AnyOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ - Value: anyOf, - KeyNode: anyOfLabel, - ValueNode: anyOfValue, - } - } - if len(oneOf) > 0 { - s.OneOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ - Value: oneOf, - KeyNode: oneOfLabel, - ValueNode: oneOfValue, - } - } - if len(allOf) > 0 { - s.AllOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ - Value: allOf, - KeyNode: allOfLabel, - ValueNode: allOfValue, - } - } - if !not.IsEmpty() { - s.Not = low.NodeReference[*SchemaProxy]{ - Value: not.Value, - KeyNode: notLabel, - ValueNode: notValue, - } - } - if !itemsIsBool && !items.IsEmpty() { - s.Items = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - A: items.Value, - }, - KeyNode: itemsLabel, - ValueNode: itemsValue, - } - } - if len(prefixItems) > 0 { - s.PrefixItems = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ - Value: prefixItems, - KeyNode: prefixItemsLabel, - ValueNode: prefixItemsValue, - } - } - if !contains.IsEmpty() { - s.Contains = low.NodeReference[*SchemaProxy]{ - Value: contains.Value, - KeyNode: containsLabel, - ValueNode: containsValue, - } - } - if !sif.IsEmpty() { - s.If = low.NodeReference[*SchemaProxy]{ - Value: sif.Value, - KeyNode: sifLabel, - ValueNode: sifValue, - } - } - if !selse.IsEmpty() { - s.Else = low.NodeReference[*SchemaProxy]{ - Value: selse.Value, - KeyNode: selseLabel, - ValueNode: selseValue, - } - } - if !sthen.IsEmpty() { - s.Then = low.NodeReference[*SchemaProxy]{ - Value: sthen.Value, - KeyNode: sthenLabel, - ValueNode: sthenValue, - } - } - if !propertyNames.IsEmpty() { - s.PropertyNames = low.NodeReference[*SchemaProxy]{ - Value: propertyNames.Value, - KeyNode: propNamesLabel, - ValueNode: propNamesValue, - } - } - if !unevalItems.IsEmpty() { - s.UnevaluatedItems = low.NodeReference[*SchemaProxy]{ - Value: unevalItems.Value, - KeyNode: unevalItemsLabel, - ValueNode: unevalItemsValue, - } - } - if !unevalIsBool && !unevalProperties.IsEmpty() { - s.UnevaluatedProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - A: unevalProperties.Value, - }, - KeyNode: unevalPropsLabel, - ValueNode: unevalPropsValue, - } - } - if !addPropsIsBool && !addProperties.IsEmpty() { - s.AdditionalProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ - Value: &SchemaDynamicValue[*SchemaProxy, bool]{ - A: addProperties.Value, - }, - KeyNode: addPropsLabel, - ValueNode: addPropsValue, - } - } - if !contentSch.IsEmpty() { - s.ContentSchema = low.NodeReference[*SchemaProxy]{ - Value: contentSch.Value, - KeyNode: contentSchLabel, - ValueNode: contentSchValue, - } - } - return nil -} - -func buildPropertyMap(ctx context.Context, parent *Schema, root *yaml.Node, idx *index.SpecIndex, label string) (*low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]], error) { - _, propLabel, propsNode := utils.FindKeyNodeFullTop(label, root.Content) - if propsNode != nil { - propertyMap := orderedmap.New[low.KeyReference[string], low.ValueReference[*SchemaProxy]]() - var currentProp *yaml.Node - for i, prop := range propsNode.Content { - if i%2 == 0 { - currentProp = prop - parent.Nodes.Store(prop.Line, prop) - continue - } - - foundCtx := ctx - foundIdx := idx - // check our prop isn't reference - refString := "" - var refNode *yaml.Node - if h, _, l := utils.IsNodeRefValue(prop); h { - ref, fIdx, err, fctx := low.LocateRefNodeWithContext(foundCtx, prop, foundIdx) - if ref != nil { - refNode = prop - prop = ref - refString = l - foundCtx = fctx - foundIdx = fIdx - } else if errors.Is(err, low.ErrExternalRefSkipped) { - refString = l - refNode = prop - } else { - return nil, fmt.Errorf("schema properties build failed: cannot find reference %s, line %d, col %d", - prop.Content[1].Value, prop.Content[1].Line, prop.Content[1].Column) - } - } - - sp := &SchemaProxy{ctx: foundCtx, kn: currentProp, vn: prop, idx: foundIdx} - sp.SetReference(refString, refNode) - - _ = sp.Build(foundCtx, currentProp, prop, foundIdx) - - propertyMap.Set(low.KeyReference[string]{ - KeyNode: currentProp, - Value: currentProp.Value, - }, low.ValueReference[*SchemaProxy]{ - Value: sp, - ValueNode: sp.vn, // use transformed node - }) - } - - return &low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]]{ - Value: propertyMap, - KeyNode: propLabel, - ValueNode: propsNode, - }, nil - } - return nil, nil -} - -// buildDependentRequiredMap builds an ordered map of string arrays for the dependentRequired property -func buildDependentRequiredMap(root *yaml.Node, label string) (*low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]], error) { - _, propLabel, propsNode := utils.FindKeyNodeFullTop(label, root.Content) - if propsNode != nil { - dependentRequiredMap := orderedmap.New[low.KeyReference[string], low.ValueReference[[]string]]() - var currentKey *yaml.Node - for i, node := range propsNode.Content { - if i%2 == 0 { - currentKey = node - continue - } - - // node should be an array of strings - if !utils.IsNodeArray(node) { - return nil, fmt.Errorf("dependentRequired value must be an array, found %v at line %d, col %d", - node.Kind, node.Line, node.Column) - } - - var requiredProps []string - for _, propNode := range node.Content { - if propNode.Kind != yaml.ScalarNode { - return nil, fmt.Errorf("dependentRequired array items must be strings, found %v at line %d, col %d", - propNode.Kind, propNode.Line, propNode.Column) - } - requiredProps = append(requiredProps, propNode.Value) - } - - dependentRequiredMap.Set(low.KeyReference[string]{ - KeyNode: currentKey, - Value: currentKey.Value, - }, low.ValueReference[[]string]{ - Value: requiredProps, - ValueNode: node, - }) - } - - return &low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]]{ - Value: dependentRequiredMap, - KeyNode: propLabel, - ValueNode: propsNode, - }, nil - } - return nil, nil -} - -// extract extensions from schema -func (s *Schema) extractExtensions(root *yaml.Node) { - s.Extensions = low.ExtractExtensions(root) -} - -// buildSchemaProxy builds out a SchemaProxy for a single node. -func buildSchemaProxy(ctx context.Context, idx *index.SpecIndex, kn, vn *yaml.Node, rf *yaml.Node, isRef bool, refLocation string) low.ValueReference[*SchemaProxy] { - // a proxy design works best here. polymorphism, pretty much guarantees that a sub-schema can - // take on circular references through polymorphism. Like the resolver, if we try and follow these - // journey's through hyperspace, we will end up creating endless amounts of threads, spinning off - // chasing down circles, that in turn spin up endless threads. - // In order to combat this, we need a schema proxy that will only resolve the schema when asked, and then - // it will only do it one level at a time. - sp := new(SchemaProxy) - - // call Build to ensure transformation happens - _ = sp.Build(ctx, kn, vn, idx) - - if isRef { - sp.SetReference(refLocation, rf) - } - return low.ValueReference[*SchemaProxy]{ - Value: sp, - ValueNode: sp.vn, // use transformed node - } -} - -// buildSchema builds out a child schema for parent schema. Expected to be a singular schema object. -func buildSchema(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex) (low.ValueReference[*SchemaProxy], error) { - if valueNode == nil { - return low.ValueReference[*SchemaProxy]{}, nil - } - - if !utils.IsNodeMap(valueNode) { - return low.ValueReference[*SchemaProxy]{}, fmt.Errorf("build schema failed: expected a single schema object for '%s', but found an array or scalar at line %d, col %d", - utils.MakeTagReadable(valueNode), valueNode.Line, valueNode.Column) - } - - isRef := false - refLocation := "" - var refNode *yaml.Node - foundCtx := ctx - foundIdx := idx - - h := false - if h, _, refLocation = utils.IsNodeRefValue(valueNode); h { - isRef = true - ref, fIdx, err, fctx := low.LocateRefNodeWithContext(foundCtx, valueNode, foundIdx) - if ref != nil { - refNode = valueNode - valueNode = ref - foundCtx = fctx - foundIdx = fIdx - } else if errors.Is(err, low.ErrExternalRefSkipped) { - refNode = valueNode - } else { - return low.ValueReference[*SchemaProxy]{}, fmt.Errorf("build schema failed: reference cannot be found: %s, line %d, col %d", - valueNode.Content[1].Value, valueNode.Content[1].Line, valueNode.Content[1].Column) - } - } - - return buildSchemaProxy(foundCtx, foundIdx, labelNode, valueNode, refNode, isRef, refLocation), nil -} - -// buildSchemaList builds out child schemas for a parent schema. Expected to be an array of schema objects. -func buildSchemaList(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex) ([]low.ValueReference[*SchemaProxy], error) { - if valueNode == nil { - return nil, nil - } - - if !utils.IsNodeArray(valueNode) { - return nil, fmt.Errorf("build schema failed: expected an array of schemas for '%s', but found an object or scalar at line %d, col %d", - utils.MakeTagReadable(valueNode), valueNode.Line, valueNode.Column) - } - - results := make([]low.ValueReference[*SchemaProxy], 0, len(valueNode.Content)) - - for _, vn := range valueNode.Content { - isRef := false - refLocation := "" - var refNode *yaml.Node - foundCtx := ctx - foundIdx := idx - - h := false - if h, _, refLocation = utils.IsNodeRefValue(vn); h { - isRef = true - ref, fIdx, err, fctx := low.LocateRefNodeWithContext(foundCtx, vn, foundIdx) - if ref != nil { - refNode = vn - vn = ref - foundCtx = fctx - foundIdx = fIdx - } else if errors.Is(err, low.ErrExternalRefSkipped) { - refNode = vn - } else { - return nil, fmt.Errorf("build schema failed: reference cannot be found: %s, line %d, col %d", - vn.Content[1].Value, vn.Content[1].Line, vn.Content[1].Column) - } - } - - r := buildSchemaProxy(foundCtx, foundIdx, vn, vn, refNode, isRef, refLocation) - results = append(results, r) - } - - return results, nil -} - // ExtractSchema will return a pointer to a NodeReference that contains a *SchemaProxy if successful. The function // will specifically look for a key node named 'schema' and extract the value mapped to that key. If the operation // fails then no NodeReference is returned and an error is returned instead. diff --git a/datamodel/low/base/schema_bench_test.go b/datamodel/low/base/schema_bench_test.go new file mode 100644 index 000000000..407d6c5b3 --- /dev/null +++ b/datamodel/low/base/schema_bench_test.go @@ -0,0 +1,108 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "go.yaml.in/yaml/v4" +) + +func benchmarkSchemaRootNode(b *testing.B) *yaml.Node { + b.Helper() + + var rootNode yaml.Node + if err := yaml.Unmarshal([]byte(test_get_schema_blob()), &rootNode); err != nil { + b.Fatalf("failed to unmarshal benchmark schema: %v", err) + } + if len(rootNode.Content) == 0 || rootNode.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark schema: empty root") + } + return rootNode.Content[0] +} + +func benchmarkBuiltSchema(b *testing.B) *Schema { + b.Helper() + + rootNode := benchmarkSchemaRootNode(b) + schema := new(Schema) + if err := low.BuildModel(rootNode, schema); err != nil { + b.Fatalf("failed to build low-level schema model: %v", err) + } + if err := schema.Build(context.Background(), rootNode, nil); err != nil { + b.Fatalf("failed to build schema: %v", err) + } + return schema +} + +func BenchmarkSchema_Build(b *testing.B) { + rootNode := benchmarkSchemaRootNode(b) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var schema Schema + if err := low.BuildModel(rootNode, &schema); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := schema.Build(ctx, rootNode, nil); err != nil { + b.Fatalf("schema build failed: %v", err) + } + } +} + +func BenchmarkSchema_QuickHash(b *testing.B) { + schema := benchmarkBuiltSchema(b) + + ClearSchemaQuickHashMap() + if hash := schema.QuickHash(); hash == 0 { + b.Fatal("benchmark setup failed: quick hash returned zero") + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ClearSchemaQuickHashMap() + if hash := schema.QuickHash(); hash == 0 { + b.Fatal("quick hash returned zero") + } + } +} + +func BenchmarkSchema_QuickHash_Cached(b *testing.B) { + schema := benchmarkBuiltSchema(b) + if hash := schema.QuickHash(); hash == 0 { + b.Fatal("benchmark setup failed: quick hash returned zero") + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + if hash := schema.QuickHash(); hash == 0 { + b.Fatal("quick hash returned zero") + } + } +} + +func BenchmarkSchema_HashSingle(b *testing.B) { + schema := benchmarkBuiltSchema(b) + if hash := schema.Hash(); hash == 0 { + b.Fatal("benchmark setup failed: hash returned zero") + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + if hash := schema.Hash(); hash == 0 { + b.Fatal("hash returned zero") + } + } +} diff --git a/datamodel/low/base/schema_build.go b/datamodel/low/base/schema_build.go new file mode 100644 index 000000000..6e944f58f --- /dev/null +++ b/datamodel/low/base/schema_build.go @@ -0,0 +1,606 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +// Build will perform a number of operations. +// Extraction of the following happens in this method: +// - Extensions +// - Type +// - ExclusiveMinimum and ExclusiveMaximum +// - Examples +// - AdditionalProperties +// - Discriminator +// - ExternalDocs +// - XML +// - Properties +// - AllOf, OneOf, AnyOf +// - Not +// - Items +// - PrefixItems +// - If +// - Else +// - Then +// - DependentSchemas +// - PatternProperties +// - PropertyNames +// - UnevaluatedItems +// - UnevaluatedProperties +// - Anchor +func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) error { + if root == nil { + return fmt.Errorf("cannot build schema from a nil node") + } + root = utils.NodeAlias(root) + utils.CheckForMergeNodes(root) + + s.reference = low.Reference{} + s.Reference = &s.reference + s.nodeStore = sync.Map{} + s.Nodes = &s.nodeStore + if root != nil && len(root.Content) > 0 { + s.NodeMap.ExtractNodes(root, false) + } else if root != nil { + s.AddNode(root.Line, root) + } + s.Index = idx + s.RootNode = root + s.context = ctx + s.index = idx + + isTransformed := false + if s.ParentProxy != nil && s.ParentProxy.TransformedRef != nil { + isTransformed = true + } + + if !isTransformed { + if h, _, _ := utils.IsNodeRefValue(root); h { + ref, _, err, fctx := low.LocateRefNodeWithContext(ctx, root, idx) + if ref != nil { + root = ref + if fctx != nil { + ctx = fctx + } + if err != nil { + if !idx.AllowCircularReferenceResolving() { + return fmt.Errorf("build schema failed: %s", err.Error()) + } + } + } else { + return fmt.Errorf("build schema failed: reference cannot be found: '%s', line %d, col %d", + root.Content[1].Value, root.Content[1].Line, root.Content[1].Column) + } + } + } + + if err := low.BuildModel(root, s); err != nil { + return err + } + + s.extractExtensions(root) + + if s.Required.Value != nil { + for _, r := range s.Required.Value { + s.AddNode(r.ValueNode.Line, r.ValueNode) + } + } + + if s.Enum.Value != nil { + for _, e := range s.Enum.Value { + s.AddNode(e.ValueNode.Line, e.ValueNode) + } + } + + _, typeLabel, typeValue := utils.FindKeyNodeFullTop(TypeLabel, root.Content) + if typeValue != nil { + if utils.IsNodeStringValue(typeValue) { + s.Type = low.NodeReference[SchemaDynamicValue[string, []low.ValueReference[string]]]{ + KeyNode: typeLabel, + ValueNode: typeValue, + Value: SchemaDynamicValue[string, []low.ValueReference[string]]{N: 0, A: typeValue.Value}, + } + } + if utils.IsNodeArray(typeValue) { + refs := make([]low.ValueReference[string], 0, len(typeValue.Content)) + for r := range typeValue.Content { + refs = append(refs, low.ValueReference[string]{ + Value: typeValue.Content[r].Value, + ValueNode: typeValue.Content[r], + }) + } + s.Type = low.NodeReference[SchemaDynamicValue[string, []low.ValueReference[string]]]{ + KeyNode: typeLabel, + ValueNode: typeValue, + Value: SchemaDynamicValue[string, []low.ValueReference[string]]{N: 1, B: refs}, + } + } + } + + _, exMinLabel, exMinValue := utils.FindKeyNodeFullTop(ExclusiveMinimumLabel, root.Content) + if exMinValue != nil { + if idx != nil { + if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { + val, _ := strconv.ParseFloat(exMinValue.Value, 64) + s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMinLabel, + ValueNode: exMinValue, + Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, + } + } + if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { + val, _ := strconv.ParseBool(exMinValue.Value) + s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMinLabel, + ValueNode: exMinValue, + Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, + } + } + } else { + if utils.IsNodeBoolValue(exMinValue) { + val, _ := strconv.ParseBool(exMinValue.Value) + s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMinLabel, + ValueNode: exMinValue, + Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, + } + } + if utils.IsNodeIntValue(exMinValue) { + val, _ := strconv.ParseFloat(exMinValue.Value, 64) + s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMinLabel, + ValueNode: exMinValue, + Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, + } + } + } + } + + _, exMaxLabel, exMaxValue := utils.FindKeyNodeFullTop(ExclusiveMaximumLabel, root.Content) + if exMaxValue != nil { + if idx != nil { + if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { + val, _ := strconv.ParseFloat(exMaxValue.Value, 64) + s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMaxLabel, + ValueNode: exMaxValue, + Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, + } + } + if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { + val, _ := strconv.ParseBool(exMaxValue.Value) + s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMaxLabel, + ValueNode: exMaxValue, + Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, + } + } + } else { + if utils.IsNodeBoolValue(exMaxValue) { + val, _ := strconv.ParseBool(exMaxValue.Value) + s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMaxLabel, + ValueNode: exMaxValue, + Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, + } + } + if utils.IsNodeIntValue(exMaxValue) { + val, _ := strconv.ParseFloat(exMaxValue.Value, 64) + s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMaxLabel, + ValueNode: exMaxValue, + Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, + } + } + } + } + + _, schemaRefLabel, schemaRefNode := utils.FindKeyNodeFullTop(SchemaTypeLabel, root.Content) + if schemaRefNode != nil { + s.SchemaTypeRef = low.NodeReference[string]{ + Value: schemaRefNode.Value, KeyNode: schemaRefLabel, ValueNode: schemaRefNode, + } + } + + _, idLabel, idNode := utils.FindKeyNodeFullTop(IdLabel, root.Content) + if idNode != nil { + s.Id = low.NodeReference[string]{ + Value: idNode.Value, KeyNode: idLabel, ValueNode: idNode, + } + } + + _, anchorLabel, anchorNode := utils.FindKeyNodeFullTop(AnchorLabel, root.Content) + if anchorNode != nil { + s.Anchor = low.NodeReference[string]{ + Value: anchorNode.Value, KeyNode: anchorLabel, ValueNode: anchorNode, + } + } + + _, dynamicAnchorLabel, dynamicAnchorNode := utils.FindKeyNodeFullTop(DynamicAnchorLabel, root.Content) + if dynamicAnchorNode != nil { + s.DynamicAnchor = low.NodeReference[string]{ + Value: dynamicAnchorNode.Value, KeyNode: dynamicAnchorLabel, ValueNode: dynamicAnchorNode, + } + } + + _, dynamicRefLabel, dynamicRefNode := utils.FindKeyNodeFullTop(DynamicRefLabel, root.Content) + if dynamicRefNode != nil { + s.DynamicRef = low.NodeReference[string]{ + Value: dynamicRefNode.Value, KeyNode: dynamicRefLabel, ValueNode: dynamicRefNode, + } + } + + _, commentLabel, commentNode := utils.FindKeyNodeFullTop(CommentLabel, root.Content) + if commentNode != nil { + s.Comment = low.NodeReference[string]{ + Value: commentNode.Value, KeyNode: commentLabel, ValueNode: commentNode, + } + } + + _, vocabLabel, vocabNode := utils.FindKeyNodeFullTop(VocabularyLabel, root.Content) + if vocabNode != nil && utils.IsNodeMap(vocabNode) { + vocabularyMap := orderedmap.New[low.KeyReference[string], low.ValueReference[bool]]() + var currentKey *yaml.Node + for i, node := range vocabNode.Content { + if i%2 == 0 { + currentKey = node + continue + } + boolVal, _ := strconv.ParseBool(node.Value) + vocabularyMap.Set(low.KeyReference[string]{ + KeyNode: currentKey, + Value: currentKey.Value, + }, low.ValueReference[bool]{ + Value: boolVal, + ValueNode: node, + }) + } + s.Vocabulary = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]]{ + Value: vocabularyMap, + KeyNode: vocabLabel, + ValueNode: vocabNode, + } + } + + _, expLabel, expNode := utils.FindKeyNodeFullTop(ExampleLabel, root.Content) + if expNode != nil { + s.Example = low.NodeReference[*yaml.Node]{Value: expNode, KeyNode: expLabel, ValueNode: expNode} + low.MergeRecursiveNodesIfLineAbsent(s.Nodes, expNode) + } + + _, expArrLabel, expArrNode := utils.FindKeyNodeFullTop(ExamplesLabel, root.Content) + if expArrNode != nil { + if utils.IsNodeArray(expArrNode) { + examples := make([]low.ValueReference[*yaml.Node], 0, len(expArrNode.Content)) + for i := range expArrNode.Content { + examples = append(examples, low.ValueReference[*yaml.Node]{Value: expArrNode.Content[i], ValueNode: expArrNode.Content[i]}) + } + s.Examples = low.NodeReference[[]low.ValueReference[*yaml.Node]]{ + Value: examples, + ValueNode: expArrNode, + KeyNode: expArrLabel, + } + low.MergeRecursiveNodesIfLineAbsent(s.Nodes, expArrNode) + } + } + + addPropsIsBool := false + addPropsBoolValue := true + _, addPLabel, addPValue := utils.FindKeyNodeFullTop(AdditionalPropertiesLabel, root.Content) + if addPValue != nil { + if utils.IsNodeBoolValue(addPValue) { + addPropsIsBool = true + addPropsBoolValue, _ = strconv.ParseBool(addPValue.Value) + } + } + if addPropsIsBool { + s.AdditionalProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + B: addPropsBoolValue, + N: 1, + }, + KeyNode: addPLabel, + ValueNode: addPValue, + } + } + + _, discLabel, discNode := utils.FindKeyNodeFullTop(DiscriminatorLabel, root.Content) + if discNode != nil { + var discriminator Discriminator + _ = low.BuildModel(discNode, &discriminator) + discriminator.KeyNode = discLabel + discriminator.RootNode = discNode + discriminator.Nodes = low.ExtractNodes(ctx, discNode) + s.Discriminator = low.NodeReference[*Discriminator]{Value: &discriminator, KeyNode: discLabel, ValueNode: discNode} + low.AppendRecursiveNodes(&discriminator, discNode) + } + + _, extDocLabel, extDocNode := utils.FindKeyNodeFullTop(ExternalDocsLabel, root.Content) + if extDocNode != nil { + var exDoc ExternalDoc + _ = low.BuildModel(extDocNode, &exDoc) + _ = exDoc.Build(ctx, extDocLabel, extDocNode, idx) + exDoc.Nodes = low.ExtractNodes(ctx, extDocNode) + s.ExternalDocs = low.NodeReference[*ExternalDoc]{Value: &exDoc, KeyNode: extDocLabel, ValueNode: extDocNode} + } + + _, xmlLabel, xmlNode := utils.FindKeyNodeFullTop(XMLLabel, root.Content) + if xmlNode != nil { + var xml XML + _ = low.BuildModel(xmlNode, &xml) + _ = xml.Build(xmlNode, idx) + xml.Nodes = low.ExtractNodes(ctx, xmlNode) + s.XML = low.NodeReference[*XML]{Value: &xml, KeyNode: xmlLabel, ValueNode: xmlNode} + } + + props, err := buildPropertyMap(ctx, s, root, idx, PropertiesLabel) + if err != nil { + return err + } + if props != nil { + s.Properties = *props + } + + props, err = buildPropertyMap(ctx, s, root, idx, DependentSchemasLabel) + if err != nil { + return err + } + if props != nil { + s.DependentSchemas = *props + } + + depReq, err := buildDependentRequiredMap(root, DependentRequiredLabel) + if err != nil { + return err + } + if depReq != nil { + s.DependentRequired = *depReq + } + + props, err = buildPropertyMap(ctx, s, root, idx, PatternPropertiesLabel) + if err != nil { + return err + } + if props != nil { + s.PatternProperties = *props + } + + itemsIsBool := false + itemsBoolValue := false + _, itemsLabel, itemsValue := utils.FindKeyNodeFullTop(ItemsLabel, root.Content) + if itemsValue != nil { + if utils.IsNodeBoolValue(itemsValue) { + itemsIsBool = true + itemsBoolValue, _ = strconv.ParseBool(itemsValue.Value) + } + } + if itemsIsBool { + s.Items = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + B: itemsBoolValue, + N: 1, + }, + KeyNode: itemsLabel, + ValueNode: itemsValue, + } + } + + unevalIsBool := false + unevalBoolValue := true + _, unevalLabel, unevalValue := utils.FindKeyNodeFullTop(UnevaluatedPropertiesLabel, root.Content) + if unevalValue != nil { + if utils.IsNodeBoolValue(unevalValue) { + unevalIsBool = true + unevalBoolValue, _ = strconv.ParseBool(unevalValue.Value) + } + } + if unevalIsBool { + s.UnevaluatedProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + B: unevalBoolValue, + N: 1, + }, + KeyNode: unevalLabel, + ValueNode: unevalValue, + } + } + + var allOf, anyOf, oneOf, prefixItems []low.ValueReference[*SchemaProxy] + var items, not, contains, sif, selse, sthen, propertyNames, unevalItems, unevalProperties, addProperties, contentSch low.ValueReference[*SchemaProxy] + + _, allOfLabel, allOfValue := utils.FindKeyNodeFullTop(AllOfLabel, root.Content) + _, anyOfLabel, anyOfValue := utils.FindKeyNodeFullTop(AnyOfLabel, root.Content) + _, oneOfLabel, oneOfValue := utils.FindKeyNodeFullTop(OneOfLabel, root.Content) + _, notLabel, notValue := utils.FindKeyNodeFullTop(NotLabel, root.Content) + _, prefixItemsLabel, prefixItemsValue := utils.FindKeyNodeFullTop(PrefixItemsLabel, root.Content) + _, containsLabel, containsValue := utils.FindKeyNodeFullTop(ContainsLabel, root.Content) + _, sifLabel, sifValue := utils.FindKeyNodeFullTop(IfLabel, root.Content) + _, selseLabel, selseValue := utils.FindKeyNodeFullTop(ElseLabel, root.Content) + _, sthenLabel, sthenValue := utils.FindKeyNodeFullTop(ThenLabel, root.Content) + _, propNamesLabel, propNamesValue := utils.FindKeyNodeFullTop(PropertyNamesLabel, root.Content) + _, unevalItemsLabel, unevalItemsValue := utils.FindKeyNodeFullTop(UnevaluatedItemsLabel, root.Content) + unevalPropsLabel, unevalPropsValue := unevalLabel, unevalValue + addPropsLabel, addPropsValue := addPLabel, addPValue + _, contentSchLabel, contentSchValue := utils.FindKeyNodeFullTop(ContentSchemaLabel, root.Content) + + if ctx == nil { + ctx = context.Background() + } + + if err := assignBuiltSchemaList(ctx, allOfLabel, allOfValue, idx, &allOf); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchemaList(ctx, anyOfLabel, anyOfValue, idx, &anyOf); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchemaList(ctx, oneOfLabel, oneOfValue, idx, &oneOf); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchemaList(ctx, prefixItemsLabel, prefixItemsValue, idx, &prefixItems); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, notLabel, notValue, idx, ¬); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, containsLabel, containsValue, idx, &contains); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if !itemsIsBool { + if err := assignBuiltSchema(ctx, itemsLabel, itemsValue, idx, &items); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + } + if err := assignBuiltSchema(ctx, sifLabel, sifValue, idx, &sif); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, selseLabel, selseValue, idx, &selse); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, sthenLabel, sthenValue, idx, &sthen); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, propNamesLabel, propNamesValue, idx, &propertyNames); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if err := assignBuiltSchema(ctx, unevalItemsLabel, unevalItemsValue, idx, &unevalItems); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + if !unevalIsBool { + if err := assignBuiltSchema(ctx, unevalPropsLabel, unevalPropsValue, idx, &unevalProperties); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + } + if !addPropsIsBool { + if err := assignBuiltSchema(ctx, addPropsLabel, addPropsValue, idx, &addProperties); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + } + if err := assignBuiltSchema(ctx, contentSchLabel, contentSchValue, idx, &contentSch); err != nil { + return fmt.Errorf("failed to build schema: %w", err) + } + + if len(anyOf) > 0 { + s.AnyOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ + Value: anyOf, + KeyNode: anyOfLabel, + ValueNode: anyOfValue, + } + } + if len(oneOf) > 0 { + s.OneOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ + Value: oneOf, + KeyNode: oneOfLabel, + ValueNode: oneOfValue, + } + } + if len(allOf) > 0 { + s.AllOf = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ + Value: allOf, + KeyNode: allOfLabel, + ValueNode: allOfValue, + } + } + if !not.IsEmpty() { + s.Not = low.NodeReference[*SchemaProxy]{ + Value: not.Value, + KeyNode: notLabel, + ValueNode: notValue, + } + } + if !itemsIsBool && !items.IsEmpty() { + s.Items = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + A: items.Value, + }, + KeyNode: itemsLabel, + ValueNode: itemsValue, + } + } + if len(prefixItems) > 0 { + s.PrefixItems = low.NodeReference[[]low.ValueReference[*SchemaProxy]]{ + Value: prefixItems, + KeyNode: prefixItemsLabel, + ValueNode: prefixItemsValue, + } + } + if !contains.IsEmpty() { + s.Contains = low.NodeReference[*SchemaProxy]{ + Value: contains.Value, + KeyNode: containsLabel, + ValueNode: containsValue, + } + } + if !sif.IsEmpty() { + s.If = low.NodeReference[*SchemaProxy]{ + Value: sif.Value, + KeyNode: sifLabel, + ValueNode: sifValue, + } + } + if !selse.IsEmpty() { + s.Else = low.NodeReference[*SchemaProxy]{ + Value: selse.Value, + KeyNode: selseLabel, + ValueNode: selseValue, + } + } + if !sthen.IsEmpty() { + s.Then = low.NodeReference[*SchemaProxy]{ + Value: sthen.Value, + KeyNode: sthenLabel, + ValueNode: sthenValue, + } + } + if !propertyNames.IsEmpty() { + s.PropertyNames = low.NodeReference[*SchemaProxy]{ + Value: propertyNames.Value, + KeyNode: propNamesLabel, + ValueNode: propNamesValue, + } + } + if !unevalItems.IsEmpty() { + s.UnevaluatedItems = low.NodeReference[*SchemaProxy]{ + Value: unevalItems.Value, + KeyNode: unevalItemsLabel, + ValueNode: unevalItemsValue, + } + } + if !unevalIsBool && !unevalProperties.IsEmpty() { + s.UnevaluatedProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + A: unevalProperties.Value, + }, + KeyNode: unevalPropsLabel, + ValueNode: unevalPropsValue, + } + } + if !addPropsIsBool && !addProperties.IsEmpty() { + s.AdditionalProperties = low.NodeReference[*SchemaDynamicValue[*SchemaProxy, bool]]{ + Value: &SchemaDynamicValue[*SchemaProxy, bool]{ + A: addProperties.Value, + }, + KeyNode: addPropsLabel, + ValueNode: addPropsValue, + } + } + if !contentSch.IsEmpty() { + s.ContentSchema = low.NodeReference[*SchemaProxy]{ + Value: contentSch.Value, + KeyNode: contentSchLabel, + ValueNode: contentSchValue, + } + } + return nil +} diff --git a/datamodel/low/base/schema_build_coverage_test.go b/datamodel/low/base/schema_build_coverage_test.go new file mode 100644 index 000000000..c2c5707f2 --- /dev/null +++ b/datamodel/low/base/schema_build_coverage_test.go @@ -0,0 +1,150 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "reflect" + "sync" + "testing" + "unsafe" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +type collectingAddNodes struct { + lines []int +} + +//go:linkname lowBuildModelFieldCache github.com/pb33f/libopenapi/datamodel/low.buildModelFieldCache +var lowBuildModelFieldCache sync.Map + +func (c *collectingAddNodes) AddNode(key int, _ *yaml.Node) { + c.lines = append(c.lines, key) +} + +func TestSchemaBuild_InvalidNestedSchemaFields(t *testing.T) { + cases := []struct { + name string + field string + value string + }{ + {name: "anyOf", field: "anyOf", value: "oops"}, + {name: "oneOf", field: "oneOf", value: "oops"}, + {name: "prefixItems", field: "prefixItems", value: "oops"}, + {name: "not", field: "not", value: "oops"}, + {name: "contains", field: "contains", value: "oops"}, + {name: "items", field: "items", value: "oops"}, + {name: "if", field: "if", value: "oops"}, + {name: "else", field: "else", value: "oops"}, + {name: "then", field: "then", value: "oops"}, + {name: "propertyNames", field: "propertyNames", value: "oops"}, + {name: "unevaluatedItems", field: "unevaluatedItems", value: "oops"}, + {name: "unevaluatedProperties", field: "unevaluatedProperties", value: "oops"}, + {name: "additionalProperties", field: "additionalProperties", value: "oops"}, + {name: "contentSchema", field: "contentSchema", value: "oops"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec := fmt.Sprintf("%s: %s\n", tc.field, tc.value) + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &root)) + + var schema Schema + require.NoError(t, low.BuildModel(root.Content[0], &schema)) + err := schema.Build(context.Background(), root.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to build schema") + }) + } +} + +func TestResolveSchemaBuildInput_NilAndRefFailures(t *testing.T) { + resolved, err := resolveSchemaBuildInput(context.Background(), nil, nil, "boom: %s") + require.NoError(t, err) + assert.Nil(t, resolved.valueNode) + assert.Nil(t, resolved.idx) + + var missingRef yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("$ref: './missing.yaml#/Pet'"), &missingRef)) + + cfg := index.CreateClosedAPIIndexConfig() + idx := index.NewSpecIndexWithConfig(&missingRef, cfg) + _, err = resolveSchemaBuildInput(context.Background(), missingRef.Content[0], idx, "boom: %s") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom: ./missing.yaml#/Pet") +} + +func TestSchemaBuild_BuildModelError(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("type: string\n"), &root)) + + var seed Schema + require.NoError(t, low.BuildModel(root.Content[0], &seed)) + + schemaType := reflect.TypeOf(Schema{}) + original, ok := lowBuildModelFieldCache.Load(schemaType) + require.True(t, ok) + + origType := reflect.TypeOf(original) + elemType := origType.Elem() + replacement := reflect.MakeSlice(origType, 1, 1) + elem := reflect.New(elemType).Elem() + setUnexportedField(elem.FieldByName("lookupKey"), "type") + setUnexportedField(elem.FieldByName("index"), 0) + setUnexportedField(elem.FieldByName("kind"), reflect.Bool) + replacement.Index(0).Set(elem) + + lowBuildModelFieldCache.Store(schemaType, replacement.Interface()) + t.Cleanup(func() { + lowBuildModelFieldCache.Store(schemaType, original) + }) + + var schema Schema + err := schema.Build(context.Background(), root.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to parse unsupported type") +} + +func TestRecursiveSchemaNodeHelpers(t *testing.T) { + low.MergeRecursiveNodesIfLineAbsent(nil, nil) + low.AppendRecursiveNodes(nil, nil) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("example:\n nested:\n value: ok\n"), &root)) + node := root.Content[0] + + var dst sync.Map + blockedLine := node.Content[0].Line + dst.Store(blockedLine, []*yaml.Node{{Value: "existing"}}) + + low.MergeRecursiveNodesIfLineAbsent(&dst, node) + + _, blocked := dst.Load(blockedLine) + assert.True(t, blocked) + + var foundNested bool + dst.Range(func(key, value any) bool { + if key.(int) == node.Content[1].Content[0].Line { + foundNested = true + } + assert.NotNil(t, value) + return true + }) + assert.True(t, foundNested) + + collector := &collectingAddNodes{} + low.AppendRecursiveNodes(collector, node) + assert.NotEmpty(t, collector.lines) +} + +func setUnexportedField(field reflect.Value, value any) { + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) +} diff --git a/datamodel/low/base/schema_build_helpers.go b/datamodel/low/base/schema_build_helpers.go new file mode 100644 index 000000000..661a1c23a --- /dev/null +++ b/datamodel/low/base/schema_build_helpers.go @@ -0,0 +1,218 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "errors" + "fmt" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +type resolvedSchemaBuildInput struct { + ctx context.Context + idx *index.SpecIndex + valueNode *yaml.Node + refNode *yaml.Node + refLocation string +} + +func buildPropertyMap(ctx context.Context, parent *Schema, root *yaml.Node, idx *index.SpecIndex, label string) (*low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]], error) { + _, propLabel, propsNode := utils.FindKeyNodeFullTop(label, root.Content) + if propsNode != nil { + propertyMap := orderedmap.New[low.KeyReference[string], low.ValueReference[*SchemaProxy]]() + for i := 0; i < len(propsNode.Content)-1; i += 2 { + currentProp := propsNode.Content[i] + prop := propsNode.Content[i+1] + parent.Nodes.Store(currentProp.Line, currentProp) + + resolved, err := resolveSchemaBuildInput(ctx, prop, idx, + "schema properties build failed: cannot find reference %s, line %d, col %d") + if err != nil { + return nil, err + } + + propertyMap.Set(low.KeyReference[string]{ + KeyNode: currentProp, + Value: currentProp.Value, + }, buildSchemaProxy(resolved.ctx, resolved.idx, currentProp, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation)) + } + + return &low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*SchemaProxy]]]{ + Value: propertyMap, + KeyNode: propLabel, + ValueNode: propsNode, + }, nil + } + return nil, nil +} + +// buildDependentRequiredMap builds an ordered map of string arrays for the dependentRequired property +func buildDependentRequiredMap(root *yaml.Node, label string) (*low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]], error) { + _, propLabel, propsNode := utils.FindKeyNodeFullTop(label, root.Content) + if propsNode != nil { + dependentRequiredMap := orderedmap.New[low.KeyReference[string], low.ValueReference[[]string]]() + for i := 0; i < len(propsNode.Content)-1; i += 2 { + currentKey := propsNode.Content[i] + node := propsNode.Content[i+1] + + if !utils.IsNodeArray(node) { + return nil, fmt.Errorf("dependentRequired value must be an array, found %v at line %d, col %d", + node.Kind, node.Line, node.Column) + } + + requiredProps := make([]string, 0, len(node.Content)) + for _, propNode := range node.Content { + if propNode.Kind != yaml.ScalarNode { + return nil, fmt.Errorf("dependentRequired array items must be strings, found %v at line %d, col %d", + propNode.Kind, propNode.Line, propNode.Column) + } + requiredProps = append(requiredProps, propNode.Value) + } + + dependentRequiredMap.Set(low.KeyReference[string]{ + KeyNode: currentKey, + Value: currentKey.Value, + }, low.ValueReference[[]string]{ + Value: requiredProps, + ValueNode: node, + }) + } + + return &low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]]{ + Value: dependentRequiredMap, + KeyNode: propLabel, + ValueNode: propsNode, + }, nil + } + return nil, nil +} + +// extract extensions from schema +func (s *Schema) extractExtensions(root *yaml.Node) { + s.Extensions = low.ExtractExtensions(root) +} + +// buildSchemaProxy builds out a SchemaProxy for a single node. +func buildSchemaProxy(ctx context.Context, idx *index.SpecIndex, kn, vn *yaml.Node, rf *yaml.Node, isRef bool, refLocation string) low.ValueReference[*SchemaProxy] { + sp := new(SchemaProxy) + if isRef { + sp.prepareForResolvedBuild(ctx, kn, vn, idx, refLocation, rf) + } else { + sp.prepareForResolvedBuild(ctx, kn, vn, idx, "", nil) + } + return low.ValueReference[*SchemaProxy]{ + Value: sp, + ValueNode: sp.vn, + } +} + +// buildSchema builds out a child schema for parent schema. Expected to be a singular schema object. +func buildSchema(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex) (low.ValueReference[*SchemaProxy], error) { + if valueNode == nil { + return low.ValueReference[*SchemaProxy]{}, nil + } + + if !utils.IsNodeMap(valueNode) { + return low.ValueReference[*SchemaProxy]{}, fmt.Errorf("build schema failed: expected a single schema object for '%s', but found an array or scalar at line %d, col %d", + utils.MakeTagReadable(valueNode), valueNode.Line, valueNode.Column) + } + + resolved, err := resolveSchemaBuildInput(ctx, valueNode, idx, + "build schema failed: reference cannot be found: %s, line %d, col %d") + if err != nil { + return low.ValueReference[*SchemaProxy]{}, err + } + + return buildSchemaProxy(resolved.ctx, resolved.idx, labelNode, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation), nil +} + +// buildSchemaList builds out child schemas for a parent schema. Expected to be an array of schema objects. +func buildSchemaList(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex) ([]low.ValueReference[*SchemaProxy], error) { + if valueNode == nil { + return nil, nil + } + + if !utils.IsNodeArray(valueNode) { + return nil, fmt.Errorf("build schema failed: expected an array of schemas for '%s', but found an object or scalar at line %d, col %d", + utils.MakeTagReadable(valueNode), valueNode.Line, valueNode.Column) + } + + results := make([]low.ValueReference[*SchemaProxy], 0, len(valueNode.Content)) + + for _, vn := range valueNode.Content { + resolved, err := resolveSchemaBuildInput(ctx, vn, idx, + "build schema failed: reference cannot be found: %s, line %d, col %d") + if err != nil { + return nil, err + } + r := buildSchemaProxy(resolved.ctx, resolved.idx, resolved.valueNode, resolved.valueNode, resolved.refNode, resolved.refLocation != "", resolved.refLocation) + results = append(results, r) + } + + return results, nil +} + +func assignBuiltSchema(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex, dst *low.ValueReference[*SchemaProxy]) error { + if valueNode == nil { + return nil + } + + res, err := buildSchema(ctx, labelNode, valueNode, idx) + if err != nil { + return err + } + *dst = res + return nil +} + +func assignBuiltSchemaList(ctx context.Context, labelNode, valueNode *yaml.Node, idx *index.SpecIndex, dst *[]low.ValueReference[*SchemaProxy]) error { + if valueNode == nil { + return nil + } + + res, err := buildSchemaList(ctx, labelNode, valueNode, idx) + if err != nil { + return err + } + *dst = res + return nil +} + +func resolveSchemaBuildInput(ctx context.Context, valueNode *yaml.Node, idx *index.SpecIndex, errFormat string) (resolvedSchemaBuildInput, error) { + resolved := resolvedSchemaBuildInput{ + ctx: ctx, + idx: idx, + valueNode: valueNode, + } + + if valueNode == nil { + return resolved, nil + } + + if hasRef, _, refLocation := utils.IsNodeRefValue(valueNode); hasRef { + ref, foundIdx, err, foundCtx := low.LocateRefNodeWithContext(ctx, valueNode, idx) + if ref != nil { + resolved.refNode = valueNode + resolved.valueNode = ref + resolved.refLocation = refLocation + resolved.ctx = foundCtx + resolved.idx = foundIdx + return resolved, nil + } + if errors.Is(err, low.ErrExternalRefSkipped) { + resolved.refNode = valueNode + resolved.refLocation = refLocation + return resolved, nil + } + return resolved, fmt.Errorf(errFormat, valueNode.Content[1].Value, valueNode.Content[1].Line, valueNode.Content[1].Column) + } + + return resolved, nil +} diff --git a/datamodel/low/base/schema_hash.go b/datamodel/low/base/schema_hash.go new file mode 100644 index 000000000..f091e54ca --- /dev/null +++ b/datamodel/low/base/schema_hash.go @@ -0,0 +1,547 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "hash/maphash" + "sort" + "strconv" + "strings" + "sync" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Hash will generate a stable hash of the SchemaDynamicValue +func (s *SchemaDynamicValue[A, B]) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if s.IsA() { + h.WriteString(low.GenerateHashString(s.A)) + } else { + h.WriteString(low.GenerateHashString(s.B)) + } + return h.Sum64() + }) +} + +// SchemaQuickHashMap is a sync.Map used to store quick hashes of schemas, used by quick hashing to prevent +// over rotation on the same schema. This map is automatically reset each time `CompareDocuments` is called by the +// `what-changed` package and each time a model is built via `BuildV3Model()` etc. +// +// This exists because to ensure deep equality checking when composing schemas using references. However this +// can cause an exhaustive deep hash calculation that chews up compute like crazy, particularly with polymorphic refs. +// The hash map means each schema is hashed once, and then the hash is reused for quick equality checking. +var SchemaQuickHashMap sync.Map + +// ClearSchemaQuickHashMap resets the schema quick-hash cache. +// Call this between document lifecycles in long-running processes to bound memory. +func ClearSchemaQuickHashMap() { + SchemaQuickHashMap.Clear() +} + +// QuickHash will calculate a hash from the values of the schema, however the hash is not very deep +// and is used for quick equality checking, This method exists because a full hash could end up churning through +// thousands of polymorphic references. With a quick hash, polymorphic properties are not included. +func (s *Schema) QuickHash() uint64 { + return s.hash(true) +} + +// Hash will calculate a hash from the values of the schema, This allows equality checking against +// Schemas defined inside an OpenAPI document. The only way to know if a schema has changed, is to hash it. +func (s *Schema) Hash() uint64 { + return s.hash(false) +} + +func (s *Schema) hash(quick bool) uint64 { + if s == nil { + return 0 + } + + key := "" + if quick { + key = s.quickHashKey() + if v, ok := SchemaQuickHashMap.Load(key); ok { + if r, k := v.(uint64); k { + return r + } + } + } + + // Use string builder pool for efficient string concatenation + sb := low.GetStringBuilder() + defer low.PutStringBuilder(sb) + var scratch []string + + // calculate a hash from every property in the schema. + if !s.SchemaTypeRef.IsEmpty() { + sb.WriteString(s.SchemaTypeRef.Value) + sb.WriteByte('|') + } + if !s.Title.IsEmpty() { + sb.WriteString(s.Title.Value) + sb.WriteByte('|') + } + if !s.MultipleOf.IsEmpty() { + sb.WriteString(strconv.FormatFloat(s.MultipleOf.Value, 'g', -1, 64)) + sb.WriteByte('|') + } + if !s.Maximum.IsEmpty() { + sb.WriteString(strconv.FormatFloat(s.Maximum.Value, 'g', -1, 64)) + sb.WriteByte('|') + } + if !s.Minimum.IsEmpty() { + sb.WriteString(strconv.FormatFloat(s.Minimum.Value, 'g', -1, 64)) + sb.WriteByte('|') + } + if !s.MaxLength.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MaxLength.Value, 10)) + sb.WriteByte('|') + } + if !s.MinLength.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MinLength.Value, 10)) + sb.WriteByte('|') + } + if !s.Pattern.IsEmpty() { + sb.WriteString(s.Pattern.Value) + sb.WriteByte('|') + } + if !s.Format.IsEmpty() { + sb.WriteString(s.Format.Value) + sb.WriteByte('|') + } + if !s.MaxItems.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MaxItems.Value, 10)) + sb.WriteByte('|') + } + if !s.MinItems.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MinItems.Value, 10)) + sb.WriteByte('|') + } + if !s.UniqueItems.IsEmpty() { + sb.WriteString(strconv.FormatBool(s.UniqueItems.Value)) + sb.WriteByte('|') + } + if !s.MaxProperties.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MaxProperties.Value, 10)) + sb.WriteByte('|') + } + if !s.MinProperties.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MinProperties.Value, 10)) + sb.WriteByte('|') + } + if !s.AdditionalProperties.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.AdditionalProperties.Value)) + sb.WriteByte('|') + } + if !s.Description.IsEmpty() { + sb.WriteString(s.Description.Value) + sb.WriteByte('|') + } + if !s.ContentEncoding.IsEmpty() { + sb.WriteString(s.ContentEncoding.Value) + sb.WriteByte('|') + } + if !s.ContentMediaType.IsEmpty() { + sb.WriteString(s.ContentMediaType.Value) + sb.WriteByte('|') + } + if !s.Default.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Default.Value)) + sb.WriteByte('|') + } + if !s.Const.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Const.Value)) + sb.WriteByte('|') + } + if !s.Nullable.IsEmpty() { + sb.WriteString(strconv.FormatBool(s.Nullable.Value)) + sb.WriteByte('|') + } + if !s.ReadOnly.IsEmpty() { + sb.WriteString(strconv.FormatBool(s.ReadOnly.Value)) + sb.WriteByte('|') + } + if !s.WriteOnly.IsEmpty() { + sb.WriteString(strconv.FormatBool(s.WriteOnly.Value)) + sb.WriteByte('|') + } + if !s.Deprecated.IsEmpty() { + sb.WriteString(strconv.FormatBool(s.Deprecated.Value)) + sb.WriteByte('|') + } + if !s.ExclusiveMaximum.IsEmpty() && s.ExclusiveMaximum.Value.IsA() { + sb.WriteString(strconv.FormatBool(s.ExclusiveMaximum.Value.A)) + sb.WriteByte('|') + } + if !s.ExclusiveMaximum.IsEmpty() && s.ExclusiveMaximum.Value.IsB() { + sb.WriteString(strconv.FormatFloat(s.ExclusiveMaximum.Value.B, 'g', -1, 64)) + sb.WriteByte('|') + } + if !s.ExclusiveMinimum.IsEmpty() && s.ExclusiveMinimum.Value.IsA() { + sb.WriteString(strconv.FormatBool(s.ExclusiveMinimum.Value.A)) + sb.WriteByte('|') + } + if !s.ExclusiveMinimum.IsEmpty() && s.ExclusiveMinimum.Value.IsB() { + sb.WriteString(strconv.FormatFloat(s.ExclusiveMinimum.Value.B, 'g', -1, 64)) + sb.WriteByte('|') + } + if !s.Type.IsEmpty() && s.Type.Value.IsA() { + sb.WriteString(s.Type.Value.A) + sb.WriteByte('|') + } + if !s.Type.IsEmpty() && s.Type.Value.IsB() { + scratch = resizeSchemaHashScratch(scratch, len(s.Type.Value.B)) + for h := range s.Type.Value.B { + scratch[h] = s.Type.Value.B[h].Value + } + writeSortedSchemaStrings(sb, scratch, false) + } + + if len(s.Required.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.Required.Value)) + for i := range s.Required.Value { + scratch[i] = s.Required.Value[i].Value + } + writeSortedSchemaStrings(sb, scratch, true) + } + + if len(s.Enum.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.Enum.Value)) + for i := range s.Enum.Value { + scratch[i] = low.ValueToString(s.Enum.Value[i].Value) + } + writeSortedSchemaStrings(sb, scratch, true) + } + + writeSchemaMapHashes(sb, s.Properties.Value) + + if s.XML.Value != nil { + sb.WriteString(low.GenerateHashString(s.XML.Value)) + sb.WriteByte('|') + } + if s.ExternalDocs.Value != nil { + sb.WriteString(low.GenerateHashString(s.ExternalDocs.Value)) + sb.WriteByte('|') + } + if s.Discriminator.Value != nil { + sb.WriteString(low.GenerateHashString(s.Discriminator.Value)) + sb.WriteByte('|') + } + + if len(s.OneOf.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.OneOf.Value)) + for i := range s.OneOf.Value { + scratch[i] = low.GenerateHashString(s.OneOf.Value[i].Value) + } + writeSortedSchemaStrings(sb, scratch, true) + } + + if len(s.AllOf.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.AllOf.Value)) + for i := range s.AllOf.Value { + scratch[i] = low.GenerateHashString(s.AllOf.Value[i].Value) + } + writeSortedSchemaStrings(sb, scratch, true) + } + + if len(s.AnyOf.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.AnyOf.Value)) + for i := range s.AnyOf.Value { + scratch[i] = low.GenerateHashString(s.AnyOf.Value[i].Value) + } + writeSortedSchemaStrings(sb, scratch, true) + } + + if !s.Not.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Not.Value)) + sb.WriteByte('|') + } + + if !s.Items.IsEmpty() && s.Items.Value.IsA() { + sb.WriteString(low.GenerateHashString(s.Items.Value.A)) + sb.WriteByte('|') + } + if !s.Items.IsEmpty() && s.Items.Value.IsB() { + sb.WriteString(strconv.FormatBool(s.Items.Value.B)) + sb.WriteByte('|') + } + if !s.If.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.If.Value)) + sb.WriteByte('|') + } + if !s.Else.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Else.Value)) + sb.WriteByte('|') + } + if !s.Then.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Then.Value)) + sb.WriteByte('|') + } + if !s.PropertyNames.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.PropertyNames.Value)) + sb.WriteByte('|') + } + if !s.UnevaluatedProperties.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.UnevaluatedProperties.Value)) + sb.WriteByte('|') + } + if !s.UnevaluatedItems.IsEmpty() { + 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('|') + } + if !s.DynamicAnchor.IsEmpty() { + sb.WriteString(s.DynamicAnchor.Value) + sb.WriteByte('|') + } + if !s.DynamicRef.IsEmpty() { + sb.WriteString(s.DynamicRef.Value) + sb.WriteByte('|') + } + if !s.Comment.IsEmpty() { + sb.WriteString(s.Comment.Value) + sb.WriteByte('|') + } + if !s.ContentSchema.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.ContentSchema.Value)) + sb.WriteByte('|') + } + writeSchemaBoolMap(sb, s.Vocabulary.Value) + + writeSchemaMapHashes(sb, s.DependentSchemas.Value) + + writeSchemaDependentRequired(sb, s.DependentRequired.Value) + + writeSchemaMapHashes(sb, s.PatternProperties.Value) + + if len(s.PrefixItems.Value) > 0 { + scratch = resizeSchemaHashScratch(scratch, len(s.PrefixItems.Value)) + for i := range s.PrefixItems.Value { + scratch[i] = low.GenerateHashString(s.PrefixItems.Value[i].Value) + } + writeSortedSchemaStrings(sb, scratch, true) + } + + writeSchemaExtensions(sb, s.Extensions) + + if s.Example.Value != nil { + sb.WriteString(low.GenerateHashString(s.Example.Value)) + sb.WriteByte('|') + } + + if !s.Contains.IsEmpty() { + sb.WriteString(low.GenerateHashString(s.Contains.Value)) + sb.WriteByte('|') + } + if !s.MinContains.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MinContains.Value, 10)) + sb.WriteByte('|') + } + if !s.MaxContains.IsEmpty() { + sb.WriteString(strconv.FormatInt(s.MaxContains.Value, 10)) + sb.WriteByte('|') + } + if !s.Examples.IsEmpty() { + for _, ex := range s.Examples.Value { + sb.WriteString(low.GenerateHashString(ex.Value)) + sb.WriteByte('|') + } + } + + h := low.WithHasher(func(hasher *maphash.Hash) uint64 { + hasher.WriteString(sb.String()) + return hasher.Sum64() + }) + if quick { + SchemaQuickHashMap.Store(key, h) + } + return h +} + +func writeSchemaMapHashes[V any](sb *strings.Builder, m *orderedmap.Map[low.KeyReference[string], low.ValueReference[V]]) { + if m == nil || m.Len() == 0 { + return + } + + type entry struct { + key string + value V + } + + entries := make([]entry, 0, m.Len()) + for k, v := range m.FromOldest() { + entries = append(entries, entry{ + key: k.Value, + value: v.Value, + }) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + for _, entry := range entries { + sb.WriteString(entry.key) + sb.WriteByte('-') + sb.WriteString(low.GenerateHashString(entry.value)) + sb.WriteByte('|') + } +} + +func resizeSchemaHashScratch(scratch []string, size int) []string { + if cap(scratch) < size { + return make([]string, size) + } + return scratch[:size] +} + +func writeSortedSchemaStrings(sb *strings.Builder, values []string, separate bool) { + if len(values) == 0 { + return + } + + sort.Strings(values) + for _, value := range values { + sb.WriteString(value) + if separate { + sb.WriteByte('|') + } + } + if !separate { + sb.WriteByte('|') + } +} + +func writeSchemaBoolMap(sb *strings.Builder, m *orderedmap.Map[low.KeyReference[string], low.ValueReference[bool]]) { + if m == nil || m.Len() == 0 { + return + } + + type entry struct { + key string + value bool + } + + entries := make([]entry, 0, m.Len()) + for k, v := range m.FromOldest() { + entries = append(entries, entry{ + key: k.Value, + value: v.Value, + }) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + for _, entry := range entries { + sb.WriteString(entry.key) + sb.WriteByte(':') + sb.WriteString(strconv.FormatBool(entry.value)) + sb.WriteByte('|') + } +} + +func writeSchemaDependentRequired(sb *strings.Builder, m *orderedmap.Map[low.KeyReference[string], low.ValueReference[[]string]]) { + if m == nil || m.Len() == 0 { + return + } + + type entry struct { + key string + values []string + } + + entries := make([]entry, 0, m.Len()) + for k, v := range m.FromOldest() { + entries = append(entries, entry{ + key: k.Value, + values: v.Value, + }) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + for _, entry := range entries { + sb.WriteString(entry.key) + sb.WriteByte(':') + for i, value := range entry.values { + sb.WriteString(value) + if i < len(entry.values)-1 { + sb.WriteByte(',') + } + } + sb.WriteByte('|') + } +} + +func writeSchemaExtensions(sb *strings.Builder, ext *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]) { + if ext == nil || ext.Len() == 0 { + return + } + + type entry struct { + key string + node *yaml.Node + } + + entries := make([]entry, 0, ext.Len()) + for k, v := range ext.FromOldest() { + entries = append(entries, entry{ + key: k.Value, + node: v.Value, + }) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].key < entries[j].key + }) + + for _, entry := range entries { + sb.WriteString(entry.key) + sb.WriteByte('-') + sb.WriteString(low.GenerateHashString(entry.node)) + sb.WriteByte('|') + } +} + +func (s *Schema) quickHashKey() string { + idx := s.GetIndex() + path := "" + if idx != nil { + path = idx.GetSpecAbsolutePath() + } + cfID := "root" + if s.Index != nil { + if s.Index.GetRolodex() != nil { + if s.Index.GetRolodex().GetId() != "" { + cfID = s.Index.GetRolodex().GetId() + } + } else { + cfID = s.Index.GetConfig().GetId() + } + } + + var keyBuf strings.Builder + keyBuf.Grow(len(path) + len(cfID) + 16) + keyBuf.WriteString(path) + keyBuf.WriteByte(':') + keyBuf.WriteString(strconv.Itoa(s.RootNode.Line)) + keyBuf.WriteByte(':') + keyBuf.WriteString(strconv.Itoa(s.RootNode.Column)) + keyBuf.WriteByte(':') + keyBuf.WriteString(cfID) + return keyBuf.String() +} diff --git a/datamodel/low/base/schema_hash_coverage_test.go b/datamodel/low/base/schema_hash_coverage_test.go new file mode 100644 index 000000000..45e588462 --- /dev/null +++ b/datamodel/low/base/schema_hash_coverage_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/stretchr/testify/assert" +) + +func TestWriteSchemaBoolMap(t *testing.T) { + var sb strings.Builder + + writeSchemaBoolMap(&sb, nil) + assert.Equal(t, "", sb.String()) + + empty := orderedmap.New[low.KeyReference[string], low.ValueReference[bool]]() + writeSchemaBoolMap(&sb, empty) + assert.Equal(t, "", sb.String()) + + values := orderedmap.New[low.KeyReference[string], low.ValueReference[bool]]() + values.Set(low.KeyReference[string]{Value: "zeta"}, low.ValueReference[bool]{Value: true}) + values.Set(low.KeyReference[string]{Value: "alpha"}, low.ValueReference[bool]{Value: false}) + + writeSchemaBoolMap(&sb, values) + assert.Equal(t, "alpha:false|zeta:true|", sb.String()) +} + +func TestWriteSchemaDependentRequired(t *testing.T) { + var sb strings.Builder + + writeSchemaDependentRequired(&sb, nil) + assert.Equal(t, "", sb.String()) + + empty := orderedmap.New[low.KeyReference[string], low.ValueReference[[]string]]() + writeSchemaDependentRequired(&sb, empty) + assert.Equal(t, "", sb.String()) + + values := orderedmap.New[low.KeyReference[string], low.ValueReference[[]string]]() + values.Set(low.KeyReference[string]{Value: "omega"}, low.ValueReference[[]string]{Value: []string{"z", "a"}}) + values.Set(low.KeyReference[string]{Value: "alpha"}, low.ValueReference[[]string]{Value: []string{"x"}}) + + writeSchemaDependentRequired(&sb, values) + assert.Equal(t, "alpha:x|omega:z,a|", sb.String()) +} + +func TestWriteSortedSchemaStrings(t *testing.T) { + var sb strings.Builder + + writeSortedSchemaStrings(&sb, nil, false) + assert.Equal(t, "", sb.String()) + + writeSortedSchemaStrings(&sb, []string{"zeta", "alpha"}, false) + assert.Equal(t, "alphazeta|", sb.String()) +} diff --git a/datamodel/low/base/schema_lookup.go b/datamodel/low/base/schema_lookup.go new file mode 100644 index 000000000..a2cc56ba6 --- /dev/null +++ b/datamodel/low/base/schema_lookup.go @@ -0,0 +1,51 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// GetIndex will return the index.SpecIndex instance attached to the Schema object +func (s *Schema) GetIndex() *index.SpecIndex { + return s.index +} + +// GetContext will return the context.Context instance used when building the Schema object +func (s *Schema) GetContext() context.Context { + return s.context +} + +// FindProperty will return a ValueReference pointer containing a SchemaProxy pointer +// from a property key name. if found +func (s *Schema) FindProperty(name string) *low.ValueReference[*SchemaProxy] { + return low.FindItemInOrderedMap[*SchemaProxy](name, s.Properties.Value) +} + +// FindDependentSchema will return a ValueReference pointer containing a SchemaProxy pointer +// from a dependent schema key name. if found (3.1+ only) +func (s *Schema) FindDependentSchema(name string) *low.ValueReference[*SchemaProxy] { + return low.FindItemInOrderedMap[*SchemaProxy](name, s.DependentSchemas.Value) +} + +// FindPatternProperty will return a ValueReference pointer containing a SchemaProxy pointer +// from a pattern property key name. if found (3.1+ only) +func (s *Schema) FindPatternProperty(name string) *low.ValueReference[*SchemaProxy] { + return low.FindItemInOrderedMap[*SchemaProxy](name, s.PatternProperties.Value) +} + +// GetExtensions returns all extensions for Schema +func (s *Schema) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return s.Extensions +} + +// GetRootNode will return the root yaml node of the Schema object +func (s *Schema) GetRootNode() *yaml.Node { + return s.RootNode +} diff --git a/datamodel/low/base/schema_proxy.go b/datamodel/low/base/schema_proxy.go index 3a25d5462..1f3d76733 100644 --- a/datamodel/low/base/schema_proxy.go +++ b/datamodel/low/base/schema_proxy.go @@ -67,6 +67,8 @@ type SchemaProxy struct { hashMu sync.Mutex // protects cachedHash + hashGen cachedHash *uint64 // protected by hashMu hashGen uint64 // generation counter for invalidation + nodeStore sync.Map + nodeMap low.NodeMap TransformedRef *yaml.Node // Original node that contained the ref before transformation *low.NodeMap } @@ -109,11 +111,28 @@ func (sp *SchemaProxy) Build(ctx context.Context, key, value *yaml.Node, idx *in } // for transformed schemas, don't set reference since it's now an allOf structure // the reference is embedded within the allOf, but the schema itself is not a pure reference - var m sync.Map - sp.NodeMap = &low.NodeMap{Nodes: &m} + sp.nodeStore = sync.Map{} + sp.nodeMap = low.NodeMap{Nodes: &sp.nodeStore} + sp.NodeMap = &sp.nodeMap return nil } +// prepareForResolvedBuild initializes proxy state when the caller has already resolved any reference metadata. +// This avoids re-running the full Build ref-detection path for child-schema helpers that already did that work. +func (sp *SchemaProxy) prepareForResolvedBuild(ctx context.Context, key, value *yaml.Node, idx *index.SpecIndex, refLocation string, refNode *yaml.Node) { + sp.kn = key + sp.idx = idx + sp.vn = value + sp.ctx = applySchemaIdScope(ctx, value, idx) + sp.Reference = low.Reference{} + if refLocation != "" { + sp.SetReference(refLocation, refNode) + } + sp.nodeStore = sync.Map{} + sp.nodeMap = low.NodeMap{Nodes: &sp.nodeStore} + sp.NodeMap = &sp.nodeMap +} + func applySchemaIdScope(ctx context.Context, node *yaml.Node, idx *index.SpecIndex) context.Context { if node == nil { return ctx diff --git a/datamodel/low/base/security_requirement.go b/datamodel/low/base/security_requirement.go index c5413631c..ba2fa5cbe 100644 --- a/datamodel/low/base/security_requirement.go +++ b/datamodel/low/base/security_requirement.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -7,6 +7,7 @@ import ( "context" "hash/maphash" "sort" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -30,6 +31,8 @@ type SecurityRequirement struct { ContainsEmptyRequirement bool // if a requirement is empty (this means it's optional) index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -47,13 +50,29 @@ func (s *SecurityRequirement) GetIndex() *index.SpecIndex { // Build will extract security requirements from the node (the structure is odd, to be honest) func (s *SecurityRequirement) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { s.KeyNode = keyNode + s.reference = low.Reference{} + s.Reference = &s.reference + s.nodeStore = sync.Map{} + s.Nodes = &s.nodeStore + s.context = ctx + s.index = idx + if root == nil { + s.RootNode = nil + s.ContainsEmptyRequirement = true + s.Requirements = low.ValueReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[[]low.ValueReference[string]]]]{ + Value: orderedmap.New[low.KeyReference[string], low.ValueReference[[]low.ValueReference[string]]](), + ValueNode: nil, + } + return nil + } root = utils.NodeAlias(root) s.RootNode = root utils.CheckForMergeNodes(root) - s.Reference = new(low.Reference) - s.Nodes = low.ExtractNodes(ctx, root) - s.context = ctx - s.index = idx + if len(root.Content) > 0 { + s.NodeMap.ExtractNodes(root, false) + } else { + s.AddNode(root.Line, root) + } var labelNode *yaml.Node valueMap := orderedmap.New[low.KeyReference[string], low.ValueReference[[]low.ValueReference[string]]]() diff --git a/datamodel/low/base/security_requirement_test.go b/datamodel/low/base/security_requirement_test.go index 2a0c97265..9ff5eed15 100644 --- a/datamodel/low/base/security_requirement_test.go +++ b/datamodel/low/base/security_requirement_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package base @@ -70,3 +70,26 @@ func TestSecurityRequirement_TestEmptyContent(t *testing.T) { _ = sr.Build(context.Background(), nil, &yaml.Node{}, nil) assert.True(t, sr.ContainsEmptyRequirement) } + +func TestSecurityRequirement_Build_NilRoot(t *testing.T) { + var sr SecurityRequirement + err := sr.Build(context.Background(), nil, nil, nil) + assert.NoError(t, err) + assert.True(t, sr.ContainsEmptyRequirement) + assert.NotNil(t, sr.Requirements.Value) + assert.Nil(t, sr.GetRootNode()) +} + +func TestSecurityRequirement_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var sr SecurityRequirement + err := sr.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + assert.True(t, sr.ContainsEmptyRequirement) + + nodes := sr.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/base/xml.go b/datamodel/low/base/xml.go index e46884310..29d383afa 100644 --- a/datamodel/low/base/xml.go +++ b/datamodel/low/base/xml.go @@ -1,8 +1,12 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + package base import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -31,19 +35,33 @@ type XML struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } // Build will extract extensions from the XML instance. func (x *XML) Build(root *yaml.Node, idx *index.SpecIndex) error { + x.reference = low.Reference{} + x.Reference = &x.reference + x.nodeStore = sync.Map{} + x.Nodes = &x.nodeStore + x.index = idx + if root == nil { + x.RootNode = nil + x.Extensions = nil + return nil + } root = utils.NodeAlias(root) utils.CheckForMergeNodes(root) x.RootNode = root - x.Reference = new(low.Reference) - x.Nodes = low.ExtractNodes(nil, root) + if len(root.Content) > 0 { + x.NodeMap.ExtractNodes(root, false) + } else { + x.AddNode(root.Line, root) + } x.Extensions = low.ExtractExtensions(root) - x.index = idx return nil } diff --git a/datamodel/low/base/xml_test.go b/datamodel/low/base/xml_test.go index 4cb632aa6..a51f02bb6 100644 --- a/datamodel/low/base/xml_test.go +++ b/datamodel/low/base/xml_test.go @@ -1,5 +1,5 @@ -// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley -// https://pb33f.io +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT package base @@ -82,3 +82,27 @@ nodeType: attribute` assert.True(t, n.Attribute.Value) assert.Equal(t, "attribute", n.NodeType.Value) } + +func TestXML_Build_NilRoot(t *testing.T) { + var n XML + err := n.Build(nil, nil) + assert.NoError(t, err) + assert.Nil(t, n.GetRootNode()) + assert.Nil(t, n.GetExtensions()) +} + +func TestXML_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n XML + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/extraction_fragment.go b/datamodel/low/extraction_fragment.go new file mode 100644 index 000000000..c775b1a2a --- /dev/null +++ b/datamodel/low/extraction_fragment.go @@ -0,0 +1,81 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "strings" + + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +// navigateReferenceFragment navigates a local JSON Pointer fragment within a YAML node tree. +// Supported fragment formats are "#/path/to/node", "/path/to/node", and "#/" for the root. +func navigateReferenceFragment(root *yaml.Node, fragment string) *yaml.Node { + if root == nil || fragment == "" { + return nil + } + + if !strings.HasPrefix(fragment, "#") && !strings.HasPrefix(fragment, "/") { + return nil + } + + path := strings.TrimPrefix(fragment, "#") + if path == "" || path == "/" { + return nil + } + + current := utils.NodeAlias(root) + if current.Kind == yaml.DocumentNode && len(current.Content) > 0 { + current = utils.NodeAlias(current.Content[0]) + } + + segments := strings.Split(strings.TrimPrefix(path, "/"), "/") + for _, segment := range segments { + if segment == "" { + continue + } + + segment = strings.ReplaceAll(segment, "~1", "/") + segment = strings.ReplaceAll(segment, "~0", "~") + + switch { + case utils.IsNodeMap(current): + current = lookupFragmentMapValue(current, segment) + case utils.IsNodeArray(current): + current = lookupFragmentSequenceValue(current, segment) + default: + return nil + } + + if current == nil { + return nil + } + } + + return utils.NodeAlias(current) +} + +func lookupFragmentMapValue(node *yaml.Node, key string) *yaml.Node { + for i := 0; i < len(node.Content)-1; i += 2 { + if node.Content[i].Value == key { + return utils.NodeAlias(node.Content[i+1]) + } + } + return nil +} + +func lookupFragmentSequenceValue(node *yaml.Node, segment string) *yaml.Node { + index := 0 + for _, c := range segment { + if c < '0' || c > '9' { + return nil + } + index = index*10 + int(c-'0') + } + if index >= len(node.Content) { + return nil + } + return utils.NodeAlias(node.Content[index]) +} diff --git a/datamodel/low/extraction_fragment_test.go b/datamodel/low/extraction_fragment_test.go new file mode 100644 index 000000000..17b54fe87 --- /dev/null +++ b/datamodel/low/extraction_fragment_test.go @@ -0,0 +1,65 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestNavigateReferenceFragment(t *testing.T) { + spec := `components: + schemas: + Thing: + type: string +list: + - zero + - one` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &root)) + + cases := []struct { + name string + node *yaml.Node + fragment string + value string + nilNode bool + }{ + {name: "nil root", node: nil, fragment: "#/components", nilNode: true}, + {name: "empty fragment", node: &root, fragment: "", nilNode: true}, + {name: "invalid prefix", node: &root, fragment: "components/schemas/Thing", nilNode: true}, + {name: "root fragment", node: &root, fragment: "#/", nilNode: true}, + {name: "skip empty segment", node: &root, fragment: "#/components//schemas/Thing/type", value: "string"}, + {name: "array index", node: &root, fragment: "#/list/1", value: "one"}, + {name: "missing map key", node: &root, fragment: "#/components/schemas/Missing", nilNode: true}, + {name: "scalar terminal", node: &root, fragment: "#/components/schemas/Thing/type/extra", nilNode: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + found := navigateReferenceFragment(tc.node, tc.fragment) + if tc.nilNode { + assert.Nil(t, found) + return + } + require.NotNil(t, found) + assert.Equal(t, tc.value, found.Value) + }) + } +} + +func TestLookupFragmentSequenceValue(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("- zero\n- one\n"), &root)) + seq := root.Content[0] + + require.NotNil(t, lookupFragmentSequenceValue(seq, "0")) + assert.Equal(t, "zero", lookupFragmentSequenceValue(seq, "0").Value) + assert.Nil(t, lookupFragmentSequenceValue(seq, "x")) + assert.Nil(t, lookupFragmentSequenceValue(seq, "9")) +} diff --git a/datamodel/low/extraction_functions.go b/datamodel/low/extraction_functions.go index 0c61a3547..31970452f 100644 --- a/datamodel/low/extraction_functions.go +++ b/datamodel/low/extraction_functions.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package low @@ -7,6 +7,11 @@ import ( "context" "errors" "fmt" + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" "hash/maphash" "net/url" "os" @@ -16,15 +21,6 @@ import ( "strconv" "strings" "sync" - - jsonpathconfig "github.com/pb33f/jsonpath/pkg/jsonpath/config" - - "github.com/pb33f/jsonpath/pkg/jsonpath" - "github.com/pb33f/libopenapi/datamodel" - "github.com/pb33f/libopenapi/index" - "github.com/pb33f/libopenapi/orderedmap" - "github.com/pb33f/libopenapi/utils" - "go.yaml.in/yaml/v4" ) // stringBuilderPool is a sync.Pool that reuses strings.Builder instances to reduce memory allocations @@ -220,6 +216,14 @@ func LocateRefNodeWithContext(ctx context.Context, root *yaml.Node, idx *index.S } } + if index.GetSchemaIdScope(ctx) == nil { + for _, candidate := range searchRefs { + if node := navigateReferenceFragment(idx.GetRootNode(), candidate); node != nil { + return utils.NodeAlias(node), idx, nil, ctx + } + } + } + rv = resolvedRef // Obtain the absolute filepath/URL of the spec in which we are trying to @@ -339,12 +343,9 @@ func LocateRefNodeWithContext(ctx context.Context, root *yaml.Node, idx *index.S // cant be found? last resort is to try a path lookup _, friendly := utils.ConvertComponentIdIntoFriendlyPathSearch(rv) if friendly != "" { - path, err := jsonpath.NewPath(friendly, jsonpathconfig.WithPropertyNameExtension(), jsonpathconfig.WithLazyContextTracking()) - if err == nil { - nodes := path.Query(idx.GetRootNode()) - if len(nodes) > 0 { - return utils.NodeAlias(nodes[0]), idx, nil, ctx - } + nodes, err := utils.FindNodesWithoutDeserializingWithOptions(idx.GetRootNode(), friendly, utils.JSONPathLookupOptions{}) + if err == nil && len(nodes) > 0 { + return utils.NodeAlias(nodes[0]), idx, nil, ctx } } return nil, idx, fmt.Errorf("reference '%s' at line %d, column %d was not found", @@ -480,7 +481,7 @@ func ExtractObject[T Buildable[N], N any](ctx context.Context, label string, roo } } } else { - _, ln, vn = utils.FindKeyNodeFull(label, root.Content) + _, ln, vn = findExtractLabelNode(label, root) if vn != nil { if h, _, rVal := utils.IsNodeRefValue(vn); h { ref, fIdx, lerr, nCtx := LocateRefNodeWithContext(ctx, vn, idx) @@ -542,6 +543,94 @@ func ExtractObject[T Buildable[N], N any](ctx context.Context, label string, roo return res, nil } +func extractArrayValueReferences[T Buildable[N], N any]( + ctx context.Context, + label string, + labelNode, valueNode *yaml.Node, + idx *index.SpecIndex, + isRef bool, +) ([]ValueReference[T], error) { + var circError error + var items []ValueReference[T] + if valueNode == nil || labelNode == nil { + return items, nil + } + if !utils.IsNodeArray(valueNode) { + + if !isRef { + return nil, fmt.Errorf("array build failed, input is not an array, line %d, column %d", valueNode.Line, valueNode.Column) + } + // if this was pulled from a ref, but it's not a sequence, check the label and see if anything comes out, + // and then check that is a sequence, if not, fail it. + _, _, fvn := utils.FindKeyNodeFullTop(label, valueNode.Content) + if fvn != nil { + if !utils.IsNodeArray(valueNode) { + return nil, fmt.Errorf("array build failed, input is not an array, line %d, column %d", valueNode.Line, valueNode.Column) + } + } + } + if len(valueNode.Content) > 0 { + items = make([]ValueReference[T], 0, len(valueNode.Content)) + } + for _, node := range valueNode.Content { + localReferenceValue := "" + foundCtx := ctx + foundIndex := idx + + var refNode *yaml.Node + + if rf, _, rv := utils.IsNodeRefValue(node); rf { + refg, fIdx, err, nCtx := LocateRefEnd(ctx, node, idx, 0) + if refg != nil { + refNode = node + node = refg + localReferenceValue = rv + foundIndex = fIdx + foundCtx = nCtx + if err != nil { + circError = err + } + } else if errors.Is(err, ErrExternalRefSkipped) { + var n T = new(N) + SetReference(n, rv, node) + v := ValueReference[T]{Value: n, ValueNode: node} + v.SetReference(rv, node) + items = append(items, v) + continue + } else { + if err != nil { + return nil, fmt.Errorf("array build failed: reference cannot be found: %s", err.Error()) + } + } + } + var n T = new(N) + err := BuildModel(node, n) + if err != nil { + return nil, err + } + berr := n.Build(foundCtx, labelNode, node, foundIndex) + if berr != nil { + return nil, berr + } + + if localReferenceValue != "" { + SetReference(n, localReferenceValue, refNode) + } + + v := ValueReference[T]{ + Value: n, + ValueNode: node, + } + v.SetReference(localReferenceValue, refNode) + + items = append(items, v) + } + if circError != nil && !idx.AllowCircularReferenceResolving() { + return items, circError + } + return items, nil +} + func SetReference(obj any, ref string, refNode *yaml.Node) { if obj == nil { return @@ -630,86 +719,29 @@ func ExtractArray[T Buildable[N], N any](ctx context.Context, label string, root } } - var items []ValueReference[T] - if vn != nil && ln != nil { - if !utils.IsNodeArray(vn) { - - if !isRef { - return []ValueReference[T]{}, nil, nil, - fmt.Errorf("array build failed, input is not an array, line %d, column %d", vn.Line, vn.Column) - } - // if this was pulled from a ref, but it's not a sequence, check the label and see if anything comes out, - // and then check that is a sequence, if not, fail it. - _, _, fvn := utils.FindKeyNodeFullTop(label, vn.Content) - if fvn != nil { - if !utils.IsNodeArray(vn) { - return []ValueReference[T]{}, nil, nil, - fmt.Errorf("array build failed, input is not an array, line %d, column %d", vn.Line, vn.Column) - } - } - } - for _, node := range vn.Content { - localReferenceValue := "" - foundCtx := ctx - foundIndex := idx - - var refNode *yaml.Node - - if rf, _, rv := utils.IsNodeRefValue(node); rf { - refg, fIdx, err, nCtx := LocateRefEnd(ctx, node, idx, 0) - if refg != nil { - refNode = node - node = refg - localReferenceValue = rv - foundIndex = fIdx - foundCtx = nCtx - if err != nil { - circError = err - } - } else if errors.Is(err, ErrExternalRefSkipped) { - var n T = new(N) - SetReference(n, rv, node) - v := ValueReference[T]{Value: n, ValueNode: node} - v.SetReference(rv, node) - items = append(items, v) - continue - } else { - if err != nil { - return []ValueReference[T]{}, nil, nil, fmt.Errorf("array build failed: reference cannot be found: %s", - err.Error()) - } - } - } - var n T = new(N) - err := BuildModel(node, n) - if err != nil { - return []ValueReference[T]{}, ln, vn, err - } - berr := n.Build(foundCtx, ln, node, foundIndex) - if berr != nil { - return nil, ln, vn, berr - } - - if localReferenceValue != "" { - SetReference(n, localReferenceValue, refNode) - } - - v := ValueReference[T]{ - Value: n, - ValueNode: node, - } - v.SetReference(localReferenceValue, refNode) - - items = append(items, v) - } + items, err := extractArrayValueReferences[T, N](ctx, label, ln, vn, idx, isRef) + if err != nil { + return items, ln, vn, err } - // include circular errors? if circError != nil && !idx.AllowCircularReferenceResolving() { return items, ln, vn, circError } return items, ln, vn, nil } +// ExtractArrayNoLookup builds an array of low-level values from an already-located YAML sequence node. +func ExtractArrayNoLookup[T Buildable[N], N any]( + ctx context.Context, + labelNode, valueNode *yaml.Node, + idx *index.SpecIndex, +) ([]ValueReference[T], error) { + label := "" + if labelNode != nil { + label = labelNode.Value + } + return extractArrayValueReferences[T, N](ctx, label, labelNode, valueNode, idx, false) +} + // ExtractMapNoLookupExtensions will extract a map of KeyReference and ValueReference from a root yaml.Node. The 'NoLookup' part // refers to the fact that there is no key supplied as part of the extraction, there is no lookup performed and the // root yaml.Node pointer is used directly. Pass a true bit to includeExtensions to include extension keys in the map. @@ -838,6 +870,7 @@ func ExtractMapNoLookup[PT Buildable[N], N any]( type mappingResult[T any] struct { k KeyReference[string] v ValueReference[T] + e error } type buildInput struct { @@ -845,6 +878,48 @@ type buildInput struct { value *yaml.Node } +func findExtractLabelNode(label string, root *yaml.Node) (keyNode *yaml.Node, labelNode *yaml.Node, valueNode *yaml.Node) { + root = utils.NodeAlias(root) + if root == nil { + return nil, nil, nil + } + if utils.IsNodeMap(root) { + keyNode, labelNode, valueNode = utils.FindKeyNodeFullTop(label, root.Content) + if valueNode != nil { + return keyNode, labelNode, valueNode + } + } + return utils.FindKeyNodeFull(label, root.Content) +} + +func collectMapBuildInputs(valueNode *yaml.Node, extensions bool) []buildInput { + if valueNode == nil || len(valueNode.Content) == 0 { + return nil + } + + inputs := make([]buildInput, 0, len(valueNode.Content)/2) + var currentLabelNode *yaml.Node + for i, en := range valueNode.Content { + en = utils.NodeAlias(en) + if i%2 == 0 { + if !extensions && strings.HasPrefix(en.Value, "x-") { + currentLabelNode = nil + continue + } + currentLabelNode = en + continue + } + if currentLabelNode == nil { + continue + } + inputs = append(inputs, buildInput{ + label: currentLabelNode, + value: en, + }) + } + return inputs +} + // ExtractMapExtensions will extract a map of KeyReference and ValueReference from a root yaml.Node. The 'label' is // used to locate the node to be extracted from the root node supplied. Supply a bit to decide if extensions should // be included or not. required in some use cases. @@ -881,7 +956,7 @@ func ExtractMapExtensions[PT Buildable[N], N any]( root.Content[1].Value) } } else { - _, labelNode, valueNode = utils.FindKeyNodeFull(label, root.Content) + _, labelNode, valueNode = findExtractLabelNode(label, root) valueNode = utils.NodeAlias(valueNode) if valueNode != nil { if h, _, _ := utils.IsNodeRefValue(valueNode); h { @@ -906,68 +981,17 @@ func ExtractMapExtensions[PT Buildable[N], N any]( } if valueNode != nil { valueMap := orderedmap.New[KeyReference[string], ValueReference[PT]]() - - in := make(chan buildInput) - out := make(chan mappingResult[PT]) - done := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(2) // input and output goroutines. - - // TranslatePipeline input. - go func() { - defer func() { - close(in) - wg.Done() - }() - var currentLabelNode *yaml.Node - for i, en := range valueNode.Content { - if !extensions { - if strings.HasPrefix(en.Value, "x-") { - continue // yo, don't pay any attention to extensions, not here anyway. - } - } - if currentLabelNode == nil && i%2 != 0 { - continue // we need a label node first, and we don't have one because of extensions. - } - - en = utils.NodeAlias(en) - if i%2 == 0 { - currentLabelNode = en - continue - } - - select { - case in <- buildInput{ - label: currentLabelNode, - value: en, - }: - case <-done: - return - } - } - }() - - // TranslatePipeline output. - go func() { - for { - result, ok := <-out - if !ok { - break - } - valueMap.Set(result.k, result.v) - } - close(done) - wg.Done() - }() + inputs := collectMapBuildInputs(valueNode, extensions) startIdx := foundIndex startCtx := foundContext - translateFunc := func(input buildInput) (mappingResult[PT], error) { + translateFunc := func(_ int, input buildInput) (mappingResult[PT], error) { en := input.value sCtx := startCtx sIdx := startIdx + var localCircErr error var refNode *yaml.Node var referenceValue string @@ -983,7 +1007,7 @@ func ExtractMapExtensions[PT Buildable[N], N any]( } sCtx = nCtx if err != nil { - circError = err + localCircErr = err } } else if errors.Is(err, ErrExternalRefSkipped) { var n PT = new(N) @@ -993,6 +1017,7 @@ func ExtractMapExtensions[PT Buildable[N], N any]( return mappingResult[PT]{ k: KeyReference[string]{KeyNode: input.label, Value: input.label.Value}, v: v, + e: localCircErr, }, nil } else { if err != nil { @@ -1026,11 +1051,17 @@ func ExtractMapExtensions[PT Buildable[N], N any]( Value: input.label.Value, }, v: v, + e: localCircErr, }, nil } - err := datamodel.TranslatePipeline[buildInput, mappingResult[PT]](in, out, translateFunc) - wg.Wait() + err := datamodel.TranslateSliceParallel(inputs, translateFunc, func(result mappingResult[PT]) error { + if result.e != nil { + circError = result.e + } + valueMap.Set(result.k, result.v) + return nil + }) if err != nil { return nil, labelNode, valueNode, err } @@ -1503,6 +1534,7 @@ func LocateRefEnd(ctx context.Context, root *yaml.Node, idx *index.SpecIndex, de } // FromReferenceMap will convert a *orderedmap.Map[KeyReference[K], ValueReference[V]] to a *orderedmap.Map[K, V] +//go:noinline func FromReferenceMap[K comparable, V any](refMap *orderedmap.Map[KeyReference[K], ValueReference[V]]) *orderedmap.Map[K, V] { om := orderedmap.New[K, V]() for k, v := range refMap.FromOldest() { @@ -1512,6 +1544,7 @@ func FromReferenceMap[K comparable, V any](refMap *orderedmap.Map[KeyReference[K } // FromReferenceMapWithFunc will convert a *orderedmap.Map[KeyReference[K], ValueReference[V]] to a *orderedmap.Map[K, VOut] using a transform function +//go:noinline func FromReferenceMapWithFunc[K comparable, V any, VOut any](refMap *orderedmap.Map[KeyReference[K], ValueReference[V]], transform func(v V) VOut) *orderedmap.Map[K, VOut] { om := orderedmap.New[K, VOut]() for k, v := range refMap.FromOldest() { diff --git a/datamodel/low/extraction_functions_bench_test.go b/datamodel/low/extraction_functions_bench_test.go new file mode 100644 index 000000000..63ec60e32 --- /dev/null +++ b/datamodel/low/extraction_functions_bench_test.go @@ -0,0 +1,119 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +const benchmarkLookupSpec = `openapi: 3.1.0 +info: + title: benchmark + version: 1.0.0 +paths: + /burger/time: + get: + responses: + '200': + description: delicious + content: + application/json: + schema: + $ref: '#/components/schemas/Thing' +components: + schemas: + Thing: + $id: "https://example.com/schemas/thing" + type: object + properties: + name: + type: string + Scoped: + $id: "https://example.com/schemas/base" + $defs: + child: + type: string +` + +func benchmarkLookupIndex(b *testing.B) *index.SpecIndex { + b.Helper() + + var rootNode yaml.Node + if err := yaml.Unmarshal([]byte(benchmarkLookupSpec), &rootNode); err != nil { + b.Fatalf("failed to unmarshal benchmark lookup spec: %v", err) + } + config := index.CreateClosedAPIIndexConfig() + config.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + return index.NewSpecIndexWithConfig(&rootNode, config) +} + +func BenchmarkLocateRefNodeWithContext_MappedReference(b *testing.B) { + idx := benchmarkLookupIndex(b) + refNode := utils.CreateRefNode("#/components/schemas/Thing") + ctx := context.Background() + + found, _, err, _ := LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("benchmark setup failed: found=%v err=%v", found != nil, err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + found, _, err, _ = LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("mapped lookup failed: found=%v err=%v", found != nil, err) + } + } +} + +func BenchmarkLocateRefNodeWithContext_LocalPathFallback(b *testing.B) { + idx := benchmarkLookupIndex(b) + refNode := utils.CreateRefNode("#/paths/~1burger~1time") + ctx := context.Background() + + found, _, err, _ := LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("benchmark setup failed: found=%v err=%v", found != nil, err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + found, _, err, _ = LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("path fallback lookup failed: found=%v err=%v", found != nil, err) + } + } +} + +func BenchmarkLocateRefNodeWithContext_SchemaIDScope(b *testing.B) { + idx := benchmarkLookupIndex(b) + refNode := utils.CreateRefNode("#/$defs/child") + ctx := index.WithSchemaIdScope(context.Background(), index.NewSchemaIdScope("https://example.com/schemas/base")) + + found, _, err, _ := LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("benchmark setup failed: found=%v err=%v", found != nil, err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + found, _, err, _ = LocateRefNodeWithContext(ctx, refNode, idx) + if err != nil || found == nil { + b.Fatalf("schema id lookup failed: found=%v err=%v", found != nil, err) + } + } +} diff --git a/datamodel/low/extraction_functions_test.go b/datamodel/low/extraction_functions_test.go index 2feca106b..c075889a8 100644 --- a/datamodel/low/extraction_functions_test.go +++ b/datamodel/low/extraction_functions_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package low @@ -1991,6 +1991,33 @@ components: } } +func TestLocateRefNodeWithContext_FriendlyPathFallback(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Thing: + type: string +` + var rootNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &rootNode)) + + idx := index.NewSpecIndexWithConfig(&rootNode, index.CreateClosedAPIIndexConfig()) + refNode := utils.CreateRefNode("components/schemas/Thing") + + found, foundIdx, err, foundCtx := LocateRefNodeWithContext(context.Background(), refNode, idx) + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, idx, foundIdx) + assert.Equal(t, context.Background(), foundCtx) + + _, _, typeNode := utils.FindKeyNodeFullTop("type", found.Content) + require.NotNil(t, typeNode) + assert.Equal(t, "string", typeNode.Value) +} + func TestApplyResolvedSchemaIdScope_EarlyReturns(t *testing.T) { ctx := applyResolvedSchemaIdScope(context.Background(), nil, nil) assert.Nil(t, index.GetSchemaIdScope(ctx)) @@ -3941,6 +3968,41 @@ func TestExtractArray_PerItem_SkipExternalRef(t *testing.T) { assert.Equal(t, "./models/Tag.yaml#/Tag", items[0].GetReference()) } +func TestExtractArrayNoLookup(t *testing.T) { + yml := `tags: + - description: hello pizza + - description: goodbye pizza` + + var cNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &cNode)) + + _, ln, vn := utils.FindKeyNodeFullTop("tags", cNode.Content[0].Content) + items, err := ExtractArrayNoLookup[*pizza](context.Background(), ln, vn, nil) + require.NoError(t, err) + require.Len(t, items, 2) + assert.Equal(t, "hello pizza", items[0].Value.Description.Value) + assert.Equal(t, "goodbye pizza", items[1].Value.Description.Value) +} + +func TestExtractArrayNoLookup_NonArray(t *testing.T) { + yml := `tags: + description: not an array` + + var cNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &cNode)) + + _, ln, vn := utils.FindKeyNodeFullTop("tags", cNode.Content[0].Content) + items, err := ExtractArrayNoLookup[*pizza](context.Background(), ln, vn, nil) + assert.Nil(t, items) + assert.Error(t, err) +} + +func TestExtractArrayValueReferences_NilNodes(t *testing.T) { + items, err := extractArrayValueReferences[*pizza](context.Background(), "tags", nil, nil, nil, false) + assert.NoError(t, err) + assert.Nil(t, items) +} + func TestExtractMapExtensions_RootRef_SkipExternalRef(t *testing.T) { yml := `components: schemas: @@ -4047,6 +4109,74 @@ local: assert.Equal(t, "./models/Pet.yaml#/Pet", petRef.GetReference()) } +func TestCollectMapBuildInputs_NilAndEmpty(t *testing.T) { + assert.Nil(t, collectMapBuildInputs(nil, false)) + assert.Nil(t, collectMapBuildInputs(&yaml.Node{}, false)) +} + +func TestCollectMapBuildInputs_SkipsExtensionsWithoutLosingFollowingEntries(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "x-extra"}, + {Kind: yaml.ScalarNode, Value: "ignored"}, + {Kind: yaml.ScalarNode, Value: "real"}, + {Kind: yaml.MappingNode}, + }, + } + + inputs := collectMapBuildInputs(root, false) + if assert.Len(t, inputs, 1) { + assert.Equal(t, "real", inputs[0].label.Value) + assert.Equal(t, yaml.MappingNode, inputs[0].value.Kind) + } +} + +func TestFindExtractLabelNode_NilAndTopLevel(t *testing.T) { + keyNode, labelNode, valueNode := findExtractLabelNode("thing", nil) + assert.Nil(t, keyNode) + assert.Nil(t, labelNode) + assert.Nil(t, valueNode) + + root := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "thing"}, + {Kind: yaml.ScalarNode, Value: "hello"}, + }, + } + + keyNode, labelNode, valueNode = findExtractLabelNode("thing", root) + if assert.NotNil(t, keyNode) && assert.NotNil(t, labelNode) && assert.NotNil(t, valueNode) { + assert.Equal(t, "thing", keyNode.Value) + assert.Equal(t, "thing", labelNode.Value) + assert.Equal(t, "hello", valueNode.Value) + } +} + +func TestFindExtractLabelNode_FallsBackToNestedSearch(t *testing.T) { + root := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "wrapper"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "thing"}, + {Kind: yaml.ScalarNode, Value: "inside"}, + }, + }, + }, + } + + keyNode, labelNode, valueNode := findExtractLabelNode("thing", root) + if assert.NotNil(t, keyNode) && assert.NotNil(t, labelNode) && assert.NotNil(t, valueNode) { + assert.Equal(t, yaml.MappingNode, keyNode.Kind) + assert.Equal(t, "thing", labelNode.Value) + assert.Equal(t, "inside", valueNode.Value) + } +} + func TestSetReference_NilEmbeddedReference(t *testing.T) { // new(refPizza) has a nil *Reference. SetReference must not panic. rp := new(refPizza) diff --git a/datamodel/low/model_builder.go b/datamodel/low/model_builder.go index 64ba72bcc..4cebe9057 100644 --- a/datamodel/low/model_builder.go +++ b/datamodel/low/model_builder.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package low @@ -15,6 +15,39 @@ import ( "go.yaml.in/yaml/v4" ) +type buildModelField struct { + lookupKey string + index int + kind reflect.Kind +} + +var buildModelFieldCache sync.Map + +func buildModelFields(modelType reflect.Type) []buildModelField { + if cached, ok := buildModelFieldCache.Load(modelType); ok { + return cached.([]buildModelField) + } + + fields := make([]buildModelField, 0, modelType.NumField()) + for i := 0; i < modelType.NumField(); i++ { + structField := modelType.Field(i) + if !structField.IsExported() || structField.Anonymous { + continue + } + if structField.Name == "Extensions" || structField.Name == "PathItems" { + continue + } + fields = append(fields, buildModelField{ + lookupKey: strings.ToLower(structField.Name), + index: i, + kind: structField.Type.Kind(), + }) + } + + actual, _ := buildModelFieldCache.LoadOrStore(modelType, fields) + return actual.([]buildModelField) +} + // BuildModel accepts a yaml.Node pointer and a model, which can be any struct. Using reflection, the model is // analyzed and the names of all the properties are extracted from the model and subsequently looked up from within // the yaml.Node.Content value. @@ -44,41 +77,21 @@ func BuildModel(node *yaml.Node, model interface{}) error { } v := reflect.ValueOf(model).Elem() - num := v.NumField() - for i := 0; i < num; i++ { - - structField := v.Type().Field(i) - fName := structField.Name - - // Skip unexported fields and embedded structs — they are not YAML-mappable - // and can cause reflect.Kind mismatches (e.g., interface fields). - if !structField.IsExported() || structField.Anonymous { - continue - } - - if fName == "Extensions" { - continue // internal construct - } - - if fName == "PathItems" { - continue // internal construct - } - - idx, ok := keyMap[strings.ToLower(fName)] + for _, modelField := range buildModelFields(v.Type()) { + idx, ok := keyMap[modelField.lookupKey] if !ok { continue } kn := utils.NodeAlias(content[idx]) vn := utils.NodeAlias(content[idx+1]) - field := v.FieldByName(fName) - kind := field.Kind() - switch kind { + field := v.Field(modelField.index) + switch modelField.kind { case reflect.Struct, reflect.Slice, reflect.Map, reflect.Pointer: vn = utils.NodeAlias(vn) SetField(&field, vn, kn) default: - return fmt.Errorf("unable to parse unsupported type: %v", kind) + return fmt.Errorf("unable to parse unsupported type: %v", modelField.kind) } } @@ -149,7 +162,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[*yaml.Node] + items := make([]NodeReference[*yaml.Node], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { items = append(items, NodeReference[*yaml.Node]{ Value: sliceItem, @@ -256,7 +269,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[string] + items := make([]NodeReference[string], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { items = append(items, NodeReference[string]{ Value: sliceItem.Value, @@ -272,7 +285,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[float32] + items := make([]NodeReference[float32], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { fv, _ := strconv.ParseFloat(sliceItem.Value, 32) items = append(items, NodeReference[float32]{ @@ -289,7 +302,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[float64] + items := make([]NodeReference[float64], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { fv, _ := strconv.ParseFloat(sliceItem.Value, 64) items = append(items, NodeReference[float64]{Value: fv, ValueNode: sliceItem}) @@ -302,7 +315,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[int] + items := make([]NodeReference[int], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { iv, _ := strconv.Atoi(sliceItem.Value) items = append(items, NodeReference[int]{ @@ -319,7 +332,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[int64] + items := make([]NodeReference[int64], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { iv, _ := strconv.ParseInt(sliceItem.Value, 10, 64) items = append(items, NodeReference[int64]{ @@ -336,7 +349,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []NodeReference[bool] + items := make([]NodeReference[bool], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { bv, _ := strconv.ParseBool(sliceItem.Value) items = append(items, NodeReference[bool]{ @@ -355,12 +368,9 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeMap(valueNode) { if field.CanSet() { items := orderedmap.New[KeyReference[string], ValueReference[string]]() - var cf *yaml.Node - for i, sliceItem := range valueNode.Content { - if i%2 == 0 { - cf = sliceItem - continue - } + for i := 0; i < len(valueNode.Content)-1; i += 2 { + cf := valueNode.Content[i] + sliceItem := valueNode.Content[i+1] items.Set(KeyReference[string]{ Value: cf.Value, KeyNode: cf, @@ -378,12 +388,9 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeMap(valueNode) { if field.CanSet() { items := orderedmap.New[KeyReference[string], ValueReference[string]]() - var cf *yaml.Node - for i, sliceItem := range valueNode.Content { - if i%2 == 0 { - cf = sliceItem - continue - } + for i := 0; i < len(valueNode.Content)-1; i += 2 { + cf := valueNode.Content[i] + sliceItem := valueNode.Content[i+1] items.Set(KeyReference[string]{ Value: cf.Value, KeyNode: cf, @@ -403,12 +410,9 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeMap(valueNode) { if field.CanSet() { items := orderedmap.New[KeyReference[string], ValueReference[string]]() - var cf *yaml.Node - for i, sliceItem := range valueNode.Content { - if i%2 == 0 { - cf = sliceItem - continue - } + for i := 0; i < len(valueNode.Content)-1; i += 2 { + cf := valueNode.Content[i] + sliceItem := valueNode.Content[i+1] items.Set(KeyReference[string]{ Value: cf.Value, KeyNode: cf, @@ -429,7 +433,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []ValueReference[string] + items := make([]ValueReference[string], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { items = append(items, ValueReference[string]{ Value: sliceItem.Value, @@ -449,7 +453,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeArray(valueNode) { if field.CanSet() { - var items []ValueReference[*yaml.Node] + items := make([]ValueReference[*yaml.Node], 0, len(valueNode.Content)) for _, sliceItem := range valueNode.Content { items = append(items, ValueReference[*yaml.Node]{ Value: sliceItem, diff --git a/datamodel/low/model_builder_bench_test.go b/datamodel/low/model_builder_bench_test.go new file mode 100644 index 000000000..f8ee3c365 --- /dev/null +++ b/datamodel/low/model_builder_bench_test.go @@ -0,0 +1,100 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func benchmarkBuildModelHotdogNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: yummy +valueName: yammy +beef: true +fat: 200 +ketchup: 200.45 +mustard: 324938249028.98234892374892374923874823974 +grilled: true +maxTemp: 250 +maxTempAlt: [1,2,3,4,5] +maxTempHigh: 7392837462032342 +drinks: + - nice + - rice + - spice +sides: + - 0.23 + - 22.23 + - 99.45 + - 22311.2234 +bigSides: + - 98237498.9872349872349872349872347982734927342983479234234234234234234 + - 9827347234234.982374982734987234987 + - 234234234.234982374982347982374982374982347 + - 987234987234987234982734.987234987234987234987234987234987234987234982734982734982734987234987234987234987 +temps: + - 1 + - 2 +highTemps: + - 827349283744710 + - 11732849090192923 +buns: + - true + - false +unknownElements: + well: + whoKnows: not me? + doYou: + love: beerToo? +lotsOfUnknowns: + - wow: + what: aTrip + - amazing: + french: fries + - amazing: + french: fries +where: + things: + are: + wild: out here + howMany: + bears: 200 +there: + oh: yeah + care: bear +allTheThings: + beer: isGood + cake: isNice` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark model: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark model: empty root") + } + return root.Content[0] +} + +func BenchmarkBuildModel_Hotdog(b *testing.B) { + rootNode := benchmarkBuildModelHotdogNode(b) + + var hd hotdog + if err := BuildModel(rootNode, &hd); err != nil { + b.Fatalf("benchmark setup failed: %v", err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + hd = hotdog{} + if err := BuildModel(rootNode, &hd); err != nil { + b.Fatalf("build model failed: %v", err) + } + } +} diff --git a/datamodel/low/node_map_merge.go b/datamodel/low/node_map_merge.go new file mode 100644 index 000000000..ec6794d58 --- /dev/null +++ b/datamodel/low/node_map_merge.go @@ -0,0 +1,59 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "sync" + + "go.yaml.in/yaml/v4" +) + +// MergeRecursiveNodesIfLineAbsent walks a node tree and adds each discovered node to dst +// unless that line already exists in the destination map. +func MergeRecursiveNodesIfLineAbsent(dst *sync.Map, node *yaml.Node) { + if dst == nil || node == nil { + return + } + + blocked := make(map[int]bool) + known := make(map[int]bool) + nodeMap := &NodeMap{Nodes: dst} + walkRecursiveNodes(node, func(current *yaml.Node) { + line := current.Line + if !known[line] { + _, blocked[line] = dst.Load(line) + known[line] = true + } + if !blocked[line] { + nodeMap.AddNode(line, current) + } + }) +} + +// AppendRecursiveNodes walks a node tree and appends each discovered node to dst. +func AppendRecursiveNodes(dst AddNodes, node *yaml.Node) { + if dst == nil || node == nil { + return + } + + walkRecursiveNodes(node, func(current *yaml.Node) { + dst.AddNode(current.Line, current) + }) +} + +func walkRecursiveNodes(node *yaml.Node, visit func(*yaml.Node)) { + if node == nil || visit == nil || node.Content == nil { + return + } + + for i := 0; i < len(node.Content); i++ { + current := node.Content[i] + if current.Line != 0 { + visit(current) + } + if len(current.Content) > 0 { + walkRecursiveNodes(current, visit) + } + } +} diff --git a/datamodel/low/node_map_merge_test.go b/datamodel/low/node_map_merge_test.go new file mode 100644 index 000000000..2cf5c5bd1 --- /dev/null +++ b/datamodel/low/node_map_merge_test.go @@ -0,0 +1,60 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +type collectingAddNodes struct { + lines []int +} + +func (c *collectingAddNodes) AddNode(key int, _ *yaml.Node) { + c.lines = append(c.lines, key) +} + +func TestNodeMapMergeHelpers(t *testing.T) { + MergeRecursiveNodesIfLineAbsent(nil, nil) + AppendRecursiveNodes(nil, nil) + walkRecursiveNodes(nil, nil) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("example:\n nested:\n value: ok\n"), &root)) + node := root.Content[0] + + var dst sync.Map + blockedLine := node.Content[0].Line + dst.Store(blockedLine, []*yaml.Node{{Value: "existing"}}) + + MergeRecursiveNodesIfLineAbsent(&dst, node) + + _, blocked := dst.Load(blockedLine) + assert.True(t, blocked) + + var foundNested bool + dst.Range(func(key, value any) bool { + if key.(int) == node.Content[1].Content[0].Line { + foundNested = true + } + assert.NotNil(t, value) + return true + }) + assert.True(t, foundNested) + + collector := &collectingAddNodes{} + AppendRecursiveNodes(collector, node) + assert.NotEmpty(t, collector.lines) + + var walked []int + walkRecursiveNodes(node, func(current *yaml.Node) { + walked = append(walked, current.Line) + }) + assert.NotEmpty(t, walked) +} diff --git a/datamodel/low/v3/build_bench_test.go b/datamodel/low/v3/build_bench_test.go new file mode 100644 index 000000000..58973311e --- /dev/null +++ b/datamodel/low/v3/build_bench_test.go @@ -0,0 +1,932 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package v3 + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "go.yaml.in/yaml/v4" +) + +func benchmarkMediaTypeRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `schema: + type: object + properties: + name: + type: string +example: + nested: + value: + - hello + - world +examples: + what: + value: + why: there + where: + value: + here: now +encoding: + chicken: + explode: true +x-rock: + and: roll` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark media type: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark media type: empty root") + } + return root.Content[0] +} + +func benchmarkParameterRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `description: michelle, meddy and maddy +required: true +deprecated: false +name: happy +in: path +allowEmptyValue: false +style: beautiful +explode: true +allowReserved: true +schema: + type: object + description: my triple M, my loves + properties: + michelle: + type: string + meddy: + type: string + maddy: + type: string +example: + michelle: my love. + maddy: my champion. + meddy: my song. +content: + family/love: + schema: + type: string + description: family love. +examples: + family: + value: + michelle: my love. + maddy: my champion. + meddy: my song. +x-family-love: + strong: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark parameter: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark parameter: empty root") + } + return root.Content[0] +} + +func benchmarkOperationRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `tags: + - create + - pizza +summary: create a pizza +description: takes ingredients and produces pizza +externalDocs: + description: docs + url: https://example.com/docs +operationId: createPizza +parameters: + - name: style + in: query + schema: + type: string +requestBody: + description: incoming pizza + content: + application/json: + schema: + type: object + properties: + name: + type: string +responses: + "200": + description: ok +callbacks: + status: + "{$request.body#/callbackUrl}": + post: + responses: + "200": + description: ok +security: + - apiKey: [] +servers: + - url: https://api.example.com +x-pizza: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark operation: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark operation: empty root") + } + return root.Content[0] +} + +func benchmarkServerRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `name: Production +url: https://{region}.api.example.com/{version} +description: regional server +variables: + region: + default: us-east-1 + description: deployment region + enum: + - us-east-1 + - eu-west-1 + version: + default: v1 + description: api version + enum: + - v1 + - v2 +x-server: + blue: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark server: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark server: empty root") + } + return root.Content[0] +} + +func benchmarkPathItemRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `summary: pizza path +description: handles pizza endpoints +parameters: + - name: orgId + in: path + required: true + schema: + type: string +servers: + - url: https://api.example.com +get: + summary: get a pizza + operationId: getPizza + responses: + "200": + description: ok +post: + summary: create a pizza + operationId: createPizza + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "201": + description: created +x-path: + fast: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark path item: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark path item: empty root") + } + return root.Content[0] +} + +func benchmarkPathsRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `"/pizza": + get: + summary: get pizza + responses: + "200": + description: ok + post: + summary: create pizza + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: created +"/burger": + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + summary: get burger + responses: + "200": + description: ok +x-menu: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark paths: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark paths: empty root") + } + return root.Content[0] +} + +func benchmarkComponentsRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `schemas: + Pet: + type: object + properties: + id: + type: string + name: + type: string +responses: + Ok: + description: ok +parameters: + petId: + name: petId + in: path + required: true + schema: + type: string +examples: + PetExample: + value: + id: 1 + name: dog +requestBodies: + PetBody: + content: + application/json: + schema: + $ref: '#/schemas/Pet' +headers: + RateLimit: + description: rate +securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +links: + PetLink: + operationId: getPet +callbacks: + PetCallback: + "{$request.body#/callbackUrl}": + post: + responses: + "200": + description: ok +pathItems: + /pets: + get: + responses: + "200": + description: ok +mediaTypes: + json: + schema: + type: string +x-components: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark components: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark components: empty root") + } + return root.Content[0] +} + +func benchmarkResponseRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `summary: success +description: some response +headers: + rate: + description: rate header +content: + application/json: + schema: + type: object + properties: + message: + type: string +links: + follow: + operationId: getThing +x-response: + good: yes` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark response: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark response: empty root") + } + return root.Content[0] +} + +func benchmarkRequestBodyRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `description: request body +required: true +content: + application/json: + schema: + type: object + properties: + name: + type: string + example: + name: pizza +x-request: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark request body: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark request body: empty root") + } + return root.Content[0] +} + +func benchmarkHeaderRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `description: header +required: true +deprecated: false +allowEmptyValue: false +style: simple +explode: true +allowReserved: false +schema: + type: object + properties: + name: + type: string +example: + name: pizza +examples: + sample: + value: + name: pie +content: + application/json: + schema: + type: string +x-header: + bright: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark header: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark header: empty root") + } + return root.Content[0] +} + +func benchmarkResponsesRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `"200": + summary: success + description: ok + headers: + rate: + description: rate limit + content: + application/json: + schema: + type: object + properties: + message: + type: string + links: + next: + operationId: nextThing +default: + description: fallback +x-responses: + hot: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark responses: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark responses: empty root") + } + return root.Content[0] +} + +func benchmarkCallbackRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `'{$request.query.queryUrl}': + post: + requestBody: + description: callback payload + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + "200": + description: ok +x-callback: + warm: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark callback: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark callback: empty root") + } + return root.Content[0] +} + +func benchmarkOAuthFlowsRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `implicit: + authorizationUrl: https://auth.example.com/authorize + tokenUrl: https://auth.example.com/token + refreshUrl: https://auth.example.com/refresh + scopes: + read: read things + write: write things +authorizationCode: + authorizationUrl: https://auth.example.com/code + tokenUrl: https://auth.example.com/token + scopes: + admin: admin things +device: + tokenUrl: https://auth.example.com/device + scopes: + device: device things +x-flows: + warm: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark oauth flows: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark oauth flows: empty root") + } + return root.Content[0] +} + +func benchmarkSecuritySchemeRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `type: oauth2 +description: auth +scheme: bearer +bearerFormat: jwt +openIdConnectUrl: https://auth.example.com/openid +oauth2MetadataUrl: https://auth.example.com/.well-known/oauth-authorization-server +deprecated: false +flows: + implicit: + authorizationUrl: https://auth.example.com/authorize + tokenUrl: https://auth.example.com/token + scopes: + read: read things + device: + tokenUrl: https://auth.example.com/device + scopes: + device: device things +x-security: + strict: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark security scheme: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark security scheme: empty root") + } + return root.Content[0] +} + +func benchmarkLinkRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `operationRef: '#/paths/~1pets/get' +operationId: getPet +parameters: + petId: $response.body#/id + traceId: $response.header.X-Trace +requestBody: $request.body#/payload +description: follow the pet +server: + url: https://api.example.com + variables: + version: + default: v1 +x-link: + bright: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark link: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark link: empty root") + } + return root.Content[0] +} + +func benchmarkEncodingRootNode(b *testing.B) *yaml.Node { + b.Helper() + + yml := `contentType: application/json +headers: + x-rate: + description: rate header + required: true + schema: + type: integer + x-mode: + description: mode header + schema: + type: string +style: form +explode: true +allowReserved: true` + + var root yaml.Node + if err := yaml.Unmarshal([]byte(yml), &root); err != nil { + b.Fatalf("failed to unmarshal benchmark encoding: %v", err) + } + if len(root.Content) == 0 || root.Content[0] == nil { + b.Fatal("failed to unmarshal benchmark encoding: empty root") + } + return root.Content[0] +} + +func BenchmarkMediaType_Build(b *testing.B) { + rootNode := benchmarkMediaTypeRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var mt MediaType + if err := low.BuildModel(rootNode, &mt); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := mt.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("media type build failed: %v", err) + } + } +} + +func BenchmarkParameter_Build(b *testing.B) { + rootNode := benchmarkParameterRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var p Parameter + if err := low.BuildModel(rootNode, &p); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := p.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("parameter build failed: %v", err) + } + } +} + +func BenchmarkOperation_Build(b *testing.B) { + rootNode := benchmarkOperationRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var o Operation + if err := low.BuildModel(rootNode, &o); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := o.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("operation build failed: %v", err) + } + } +} + +func BenchmarkServer_Build(b *testing.B) { + rootNode := benchmarkServerRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var s Server + if err := low.BuildModel(rootNode, &s); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := s.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("server build failed: %v", err) + } + } +} + +func BenchmarkPathItem_Build(b *testing.B) { + rootNode := benchmarkPathItemRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var p PathItem + if err := low.BuildModel(rootNode, &p); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := p.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("path item build failed: %v", err) + } + } +} + +func BenchmarkPaths_Build(b *testing.B) { + rootNode := benchmarkPathsRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var p Paths + if err := low.BuildModel(rootNode, &p); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := p.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("paths build failed: %v", err) + } + } +} + +func BenchmarkComponents_Build(b *testing.B) { + rootNode := benchmarkComponentsRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var c Components + if err := low.BuildModel(rootNode, &c); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := c.Build(ctx, rootNode, idx); err != nil { + b.Fatalf("components build failed: %v", err) + } + } +} + +func BenchmarkResponse_Build(b *testing.B) { + rootNode := benchmarkResponseRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var r Response + if err := low.BuildModel(rootNode, &r); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := r.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("response build failed: %v", err) + } + } +} + +func BenchmarkRequestBody_Build(b *testing.B) { + rootNode := benchmarkRequestBodyRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var rb RequestBody + if err := low.BuildModel(rootNode, &rb); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := rb.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("request body build failed: %v", err) + } + } +} + +func BenchmarkHeader_Build(b *testing.B) { + rootNode := benchmarkHeaderRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var h Header + if err := low.BuildModel(rootNode, &h); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := h.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("header build failed: %v", err) + } + } +} + +func BenchmarkResponses_Build(b *testing.B) { + rootNode := benchmarkResponsesRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var r Responses + if err := low.BuildModel(rootNode, &r); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := r.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("responses build failed: %v", err) + } + } +} + +func BenchmarkCallback_Build(b *testing.B) { + rootNode := benchmarkCallbackRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var cb Callback + if err := low.BuildModel(rootNode, &cb); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := cb.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("callback build failed: %v", err) + } + } +} + +func BenchmarkOAuthFlows_Build(b *testing.B) { + rootNode := benchmarkOAuthFlowsRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var flows OAuthFlows + if err := low.BuildModel(rootNode, &flows); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := flows.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("oauth flows build failed: %v", err) + } + } +} + +func BenchmarkSecurityScheme_Build(b *testing.B) { + rootNode := benchmarkSecuritySchemeRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var scheme SecurityScheme + if err := low.BuildModel(rootNode, &scheme); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := scheme.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("security scheme build failed: %v", err) + } + } +} + +func BenchmarkLink_Build(b *testing.B) { + rootNode := benchmarkLinkRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var link Link + if err := low.BuildModel(rootNode, &link); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := link.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("link build failed: %v", err) + } + } +} + +func BenchmarkEncoding_Build(b *testing.B) { + rootNode := benchmarkEncodingRootNode(b) + idx := index.NewSpecIndex(rootNode) + ctx := context.Background() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + var encoding Encoding + if err := low.BuildModel(rootNode, &encoding); err != nil { + b.Fatalf("build model failed: %v", err) + } + if err := encoding.Build(ctx, nil, rootNode, idx); err != nil { + b.Fatalf("encoding build failed: %v", err) + } + } +} diff --git a/datamodel/low/v3/callback.go b/datamodel/low/v3/callback.go index b1014745e..1642b3764 100644 --- a/datamodel/low/v3/callback.go +++ b/datamodel/low/v3/callback.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -6,6 +6,7 @@ package v3 import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" @@ -29,6 +30,8 @@ type Callback struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -66,14 +69,21 @@ func (cb *Callback) FindExpression(exp string) *low.ValueReference[*PathItem] { // Build will extract extensions, expressions and PathItem objects for Callback func (cb *Callback) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { cb.KeyNode = keyNode - cb.Reference = new(low.Reference) + cb.reference = low.Reference{} + cb.Reference = &cb.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { cb.SetReference(ref, root) } root = utils.NodeAlias(root) cb.RootNode = root utils.CheckForMergeNodes(root) - cb.Nodes = low.ExtractNodes(ctx, root) + cb.nodeStore = sync.Map{} + cb.Nodes = &cb.nodeStore + if len(root.Content) > 0 { + cb.NodeMap.ExtractNodes(root, false) + } else { + cb.AddNode(root.Line, root) + } cb.Extensions = low.ExtractExtensions(root) cb.context = ctx cb.index = idx diff --git a/datamodel/low/v3/callback_test.go b/datamodel/low/v3/callback_test.go index 54c5bcc55..7ee77f992 100644 --- a/datamodel/low/v3/callback_test.go +++ b/datamodel/low/v3/callback_test.go @@ -151,3 +151,20 @@ beer: assert.Equal(t, n.Hash(), n2.Hash()) assert.Equal(t, 2, orderedmap.Len(n.GetExtensions())) } + +func TestCallback_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var cb Callback + err := low.BuildModel(scalar.Content[0], &cb) + assert.NoError(t, err) + + err = cb.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := cb.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) + assert.Equal(t, 0, orderedmap.Len(cb.Expression)) +} diff --git a/datamodel/low/v3/components.go b/datamodel/low/v3/components.go index 7edde619f..2f792c2d4 100644 --- a/datamodel/low/v3/components.go +++ b/datamodel/low/v3/components.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -41,6 +41,8 @@ type Components struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -169,8 +171,15 @@ func (co *Components) FindMediaType(mediaType string) *low.ValueReference[*Media func (co *Components) Build(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) error { root = utils.NodeAlias(root) utils.CheckForMergeNodes(root) - co.Reference = new(low.Reference) - co.Nodes = low.ExtractNodes(ctx, root) + co.reference = low.Reference{} + co.Reference = &co.reference + co.nodeStore = sync.Map{} + co.Nodes = &co.nodeStore + if len(root.Content) > 0 { + co.NodeMap.ExtractNodes(root, false) + } else { + co.AddNode(root.Line, root) + } co.Extensions = low.ExtractExtensions(root) low.ExtractExtensionNodes(ctx, co.Extensions, co.Nodes) co.RootNode = root @@ -277,48 +286,20 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe return emptyResult, fmt.Errorf("node is array, cannot be used in components: line %d, column %d", nodeValue.Line, nodeValue.Column) } - in := make(chan componentInput) - out := make(chan componentBuildResult[T]) - done := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(2) // input and output goroutines. - - // Send input. - go func() { - defer func() { - close(in) - wg.Done() - }() - var currentLabel *yaml.Node - for i, node := range nodeValue.Content { - // always ignore extensions - if i%2 == 0 { - currentLabel = node - continue - } - - select { - case in <- componentInput{ - node: node, - currentLabel: currentLabel, - }: - case <-done: - return - } - } - }() - - // Collect output. - go func() { - for result := range out { - componentValues.Set(result.key, result.value) + inputs := make([]componentInput, 0, len(nodeValue.Content)/2) + var currentLabel *yaml.Node + for i, node := range nodeValue.Content { + if i%2 == 0 { + currentLabel = node + continue } - close(done) - wg.Done() - }() + inputs = append(inputs, componentInput{ + node: node, + currentLabel: currentLabel, + }) + } - // Translate. - translateFunc := func(value componentInput) (componentBuildResult[T], error) { + translateFunc := func(_ int, value componentInput) (componentBuildResult[T], error) { var n T = new(N) currentLabel := value.currentLabel node := value.node @@ -363,8 +344,10 @@ func extractComponentValues[T low.Buildable[N], N any](ctx context.Context, labe }, }, nil } - err := datamodel.TranslatePipeline[componentInput, componentBuildResult[T]](in, out, translateFunc) - wg.Wait() + err := datamodel.TranslateSliceParallel(inputs, translateFunc, func(result componentBuildResult[T]) error { + componentValues.Set(result.key, result.value) + return nil + }) if err != nil { return emptyResult, err } diff --git a/datamodel/low/v3/components_test.go b/datamodel/low/v3/components_test.go index f3e8390ed..93c720ff8 100644 --- a/datamodel/low/v3/components_test.go +++ b/datamodel/low/v3/components_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -119,6 +119,21 @@ func TestComponents_Build_Success(t *testing.T) { assert.NotNil(t, n.GetIndex()) } +func TestComponents_Build_ScalarRoot(t *testing.T) { + var idxNode yaml.Node + mErr := yaml.Unmarshal([]byte("nope"), &idxNode) + assert.NoError(t, mErr) + + var n Components + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], nil) + assert.NoError(t, err) + assert.NotNil(t, n.GetRootNode()) + assert.NotNil(t, n.GetKeyNode()) +} + func TestComponents_Build_Success_Skip(t *testing.T) { low.ClearHashCache() yml := `components:` diff --git a/datamodel/low/v3/create_document.go b/datamodel/low/v3/create_document.go index 2a51fe72f..66b4db89f 100644 --- a/datamodel/low/v3/create_document.go +++ b/datamodel/low/v3/create_document.go @@ -1,3 +1,6 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + package v3 import ( @@ -16,8 +19,106 @@ import ( "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" ) +type documentTopLevelNode struct { + key *yaml.Node + value *yaml.Node +} + +type documentTopLevelNodes struct { + version documentTopLevelNode + jsonSchemaDialect documentTopLevelNode + self documentTopLevelNode + info documentTopLevelNode + servers documentTopLevelNode + tags documentTopLevelNode + components documentTopLevelNode + security documentTopLevelNode + externalDocs documentTopLevelNode + paths documentTopLevelNode + webhooks documentTopLevelNode +} + +func selectDocumentNode(root *yaml.Node, preferred documentTopLevelNode, label string, topOnly bool) documentTopLevelNode { + if preferred.value != nil { + return preferred + } + root = utils.NodeAlias(root) + if root == nil { + return documentTopLevelNode{} + } + if topOnly { + _, key, value := utils.FindKeyNodeFullTop(label, root.Content) + return documentTopLevelNode{key: key, value: value} + } + _, key, value := utils.FindKeyNodeFull(label, root.Content) + return documentTopLevelNode{key: key, value: value} +} + +func collectDocumentTopLevelNodes(root *yaml.Node) documentTopLevelNodes { + root = utils.NodeAlias(root) + var nodes documentTopLevelNodes + if root == nil { + return nodes + } + + content := root.Content + for i := 0; i+1 < len(content); i += 2 { + keyNode := utils.NodeAlias(content[i]) + valueNode := utils.NodeAlias(content[i+1]) + switch keyNode.Value { + case OpenAPILabel: + if nodes.version.value == nil { + nodes.version = documentTopLevelNode{key: keyNode, value: valueNode} + } + case JSONSchemaDialectLabel: + if nodes.jsonSchemaDialect.value == nil { + nodes.jsonSchemaDialect = documentTopLevelNode{key: keyNode, value: valueNode} + } + case SelfLabel: + if nodes.self.value == nil { + nodes.self = documentTopLevelNode{key: keyNode, value: valueNode} + } + case base.InfoLabel: + if nodes.info.value == nil { + nodes.info = documentTopLevelNode{key: keyNode, value: valueNode} + } + case ServersLabel: + if nodes.servers.value == nil { + nodes.servers = documentTopLevelNode{key: keyNode, value: valueNode} + } + case base.TagsLabel: + if nodes.tags.value == nil { + nodes.tags = documentTopLevelNode{key: keyNode, value: valueNode} + } + case ComponentsLabel: + if nodes.components.value == nil { + nodes.components = documentTopLevelNode{key: keyNode, value: valueNode} + } + case SecurityLabel: + if nodes.security.value == nil { + nodes.security = documentTopLevelNode{key: keyNode, value: valueNode} + } + case base.ExternalDocsLabel: + if nodes.externalDocs.value == nil { + nodes.externalDocs = documentTopLevelNode{key: keyNode, value: valueNode} + } + case PathsLabel: + if nodes.paths.value == nil { + nodes.paths = documentTopLevelNode{key: keyNode, value: valueNode} + } + case WebhooksLabel: + if nodes.webhooks.value == nil { + nodes.webhooks = documentTopLevelNode{key: keyNode, value: valueNode} + } + } + } + + return nodes +} + // CreateDocument will create a new Document instance from the provided SpecInfo. // // Deprecated: Use CreateDocumentFromConfig instead. This function will be removed in a later version, it @@ -32,14 +133,17 @@ func CreateDocumentFromConfig(info *datamodel.SpecInfo, config *datamodel.Docume } func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfiguration) (*Document, error) { - _, labelNode, versionNode := utils.FindKeyNodeFull(OpenAPILabel, info.RootNode.Content) + rootNode := utils.NodeAlias(info.RootNode.Content[0]) + topNodes := collectDocumentTopLevelNodes(rootNode) + versionNodeRef := selectDocumentNode(rootNode, topNodes.version, OpenAPILabel, false) + labelNode, versionNode := versionNodeRef.key, versionNodeRef.value var version low.NodeReference[string] if versionNode == nil { return nil, errors.New("no openapi version/tag found, cannot create document") } version = low.NodeReference[string]{Value: versionNode.Value, KeyNode: labelNode, ValueNode: versionNode} doc := Document{Version: version} - doc.Nodes = low.ExtractNodes(nil, info.RootNode.Content[0]) + doc.Nodes = low.ExtractNodes(nil, rootNode) // create an index config and shadow the document configuration. idxConfig := index.CreateClosedAPIIndexConfig() idxConfig.SpecInfo = info @@ -200,11 +304,12 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur modelContext := base.ModelContext{SchemaCache: &cacheMap} ctx := context.WithValue(context.Background(), "modelCtx", &modelContext) - doc.Extensions = low.ExtractExtensions(info.RootNode.Content[0]) + doc.Extensions = low.ExtractExtensions(rootNode) low.ExtractExtensionNodes(ctx, doc.Extensions, doc.Nodes) // if set, extract jsonSchemaDialect (3.1) - _, dialectLabel, dialectNode := utils.FindKeyNodeFull(JSONSchemaDialectLabel, info.RootNode.Content) + dialectRef := selectDocumentNode(rootNode, topNodes.jsonSchemaDialect, JSONSchemaDialectLabel, false) + dialectLabel, dialectNode := dialectRef.key, dialectRef.value if dialectNode != nil { doc.JsonSchemaDialect = low.NodeReference[string]{ Value: dialectNode.Value, KeyNode: dialectLabel, ValueNode: dialectNode, @@ -212,24 +317,15 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur } // if set, extract $self (3.2) - _, selfLabel, selfNode := utils.FindKeyNodeFull(SelfLabel, info.RootNode.Content) + selfRef := selectDocumentNode(rootNode, topNodes.self, SelfLabel, false) + selfLabel, selfNode := selfRef.key, selfRef.value if selfNode != nil { doc.Self = low.NodeReference[string]{ Value: selfNode.Value, KeyNode: selfLabel, ValueNode: selfNode, } } - runExtraction := func(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex, - runFunc func(ctx context.Context, i *datamodel.SpecInfo, d *Document, idx *index.SpecIndex) error, - ers *[]error, - wg *sync.WaitGroup, - ) { - if er := runFunc(ctx, info, doc, idx); er != nil { - *ers = append(*ers, er) - } - wg.Done() - } - extractionFuncs := []func(ctx context.Context, i *datamodel.SpecInfo, d *Document, idx *index.SpecIndex) error{ + extractionFuncs := []func(ctx context.Context, root *yaml.Node, n documentTopLevelNodes, d *Document, idx *index.SpecIndex) error{ extractInfo, extractServers, extractTags, @@ -241,12 +337,20 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur } wg.Add(len(extractionFuncs)) + var errsMu sync.Mutex if config.Logger != nil { config.Logger.Debug("running extractions") } now = time.Now() for _, f := range extractionFuncs { - runExtraction(ctx, info, &doc, rolodex.GetRootIndex(), f, &errs, &wg) + go func(runFunc func(ctx context.Context, root *yaml.Node, n documentTopLevelNodes, d *Document, idx *index.SpecIndex) error) { + defer wg.Done() + if er := runFunc(ctx, rootNode, topNodes, &doc, rolodex.GetRootIndex()); er != nil { + errsMu.Lock() + errs = append(errs, er) + errsMu.Unlock() + } + }(f) } wg.Wait() done = time.Duration(time.Since(now).Milliseconds()) @@ -256,8 +360,9 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur return &doc, errors.Join(errs...) } -func extractInfo(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - _, ln, vn := utils.FindKeyNodeFullTop(base.InfoLabel, info.RootNode.Content[0].Content) +func extractInfo(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + nodeRef := selectDocumentNode(root, nodes.info, base.InfoLabel, true) + ln, vn := nodeRef.key, nodeRef.value if vn != nil { ir := base.Info{} _ = low.BuildModel(vn, &ir) @@ -268,32 +373,29 @@ func extractInfo(ctx context.Context, info *datamodel.SpecInfo, doc *Document, i return nil } -func extractSecurity(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - sec, ln, vn, err := low.ExtractArray[*base.SecurityRequirement](ctx, SecurityLabel, info.RootNode.Content[0], idx) +func extractSecurity(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + sec, ln, vn, err := low.ExtractArray[*base.SecurityRequirement](ctx, SecurityLabel, root, idx) if err != nil { return err } if vn != nil && ln != nil { - doc.Security = low.NodeReference[[]low.ValueReference[*base.SecurityRequirement]]{ - Value: sec, - KeyNode: ln, - ValueNode: vn, - } + doc.Security = low.NodeReference[[]low.ValueReference[*base.SecurityRequirement]]{Value: sec, KeyNode: ln, ValueNode: vn} } return nil } -func extractExternalDocs(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - extDocs, dErr := low.ExtractObject[*base.ExternalDoc](ctx, base.ExternalDocsLabel, info.RootNode.Content[0], idx) - if dErr != nil { - return dErr +func extractExternalDocs(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + extDocs, err := low.ExtractObject[*base.ExternalDoc](ctx, base.ExternalDocsLabel, root, idx) + if err != nil { + return err } doc.ExternalDocs = extDocs return nil } -func extractComponents(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - _, ln, vn := utils.FindKeyNodeFullTop(ComponentsLabel, info.RootNode.Content[0].Content) +func extractComponents(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + nodeRef := selectDocumentNode(root, nodes.components, ComponentsLabel, true) + ln, vn := nodeRef.key, nodeRef.value if vn != nil { ir := Components{} _ = low.BuildModel(vn, &ir) @@ -307,8 +409,9 @@ func extractComponents(ctx context.Context, info *datamodel.SpecInfo, doc *Docum return nil } -func extractServers(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - _, ln, vn := utils.FindKeyNodeFull(ServersLabel, info.RootNode.Content[0].Content) +func extractServers(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + nodeRef := selectDocumentNode(root, nodes.servers, ServersLabel, false) + ln, vn := nodeRef.key, nodeRef.value if vn != nil { if utils.IsNodeArray(vn) { var servers []low.ValueReference[*Server] @@ -333,8 +436,9 @@ func extractServers(ctx context.Context, info *datamodel.SpecInfo, doc *Document return nil } -func extractTags(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - _, ln, vn := utils.FindKeyNodeFull(base.TagsLabel, info.RootNode.Content[0].Content) +func extractTags(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + nodeRef := selectDocumentNode(root, nodes.tags, base.TagsLabel, false) + ln, vn := nodeRef.key, nodeRef.value if vn != nil { if utils.IsNodeArray(vn) { var tags []low.ValueReference[*base.Tag] @@ -361,8 +465,9 @@ func extractTags(ctx context.Context, info *datamodel.SpecInfo, doc *Document, i return nil } -func extractPaths(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - _, ln, vn := utils.FindKeyNodeFull(PathsLabel, info.RootNode.Content[0].Content) +func extractPaths(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + nodeRef := selectDocumentNode(root, nodes.paths, PathsLabel, false) + ln, vn := nodeRef.key, nodeRef.value if vn != nil { ir := Paths{} err := ir.Build(ctx, ln, vn, idx) @@ -375,17 +480,13 @@ func extractPaths(ctx context.Context, info *datamodel.SpecInfo, doc *Document, return nil } -func extractWebhooks(ctx context.Context, info *datamodel.SpecInfo, doc *Document, idx *index.SpecIndex) error { - hooks, hooksL, hooksN, eErr := low.ExtractMap[*PathItem](ctx, WebhooksLabel, info.RootNode, idx) - if eErr != nil { - return eErr +func extractWebhooks(ctx context.Context, root *yaml.Node, nodes documentTopLevelNodes, doc *Document, idx *index.SpecIndex) error { + hooks, hooksL, hooksN, err := low.ExtractMap[*PathItem](ctx, WebhooksLabel, root, idx) + if err != nil { + return err } - if hooks != nil { - doc.Webhooks = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*PathItem]]]{ - Value: hooks, - KeyNode: hooksL, - ValueNode: hooksN, - } + if hooksN != nil && hooksL != nil { + doc.Webhooks = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*PathItem]]]{Value: hooks, KeyNode: hooksL, ValueNode: hooksN} for k, v := range hooks.FromOldest() { v.Value.Nodes.Store(k.KeyNode.Line, k.KeyNode) } diff --git a/datamodel/low/v3/create_document_test.go b/datamodel/low/v3/create_document_test.go index e03689a41..701a415f3 100644 --- a/datamodel/low/v3/create_document_test.go +++ b/datamodel/low/v3/create_document_test.go @@ -1,3 +1,6 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + package v3 import ( @@ -14,9 +17,11 @@ import ( "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/datamodel/low/base" "github.com/pb33f/libopenapi/orderedmap" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" ) var doc *Document @@ -43,6 +48,12 @@ func BenchmarkCreateDocument(b *testing.B) { } } +func TestSelectDocumentNode_NilRoot(t *testing.T) { + node := selectDocumentNode(nil, documentTopLevelNode{}, OpenAPILabel, true) + assert.Nil(t, node.key) + assert.Nil(t, node.value) +} + func TestCreateDocument_SelfWithHttpURL(t *testing.T) { low.ClearHashCache() yml := `openapi: 3.2.0 @@ -367,6 +378,23 @@ func TestCreateDocument(t *testing.T) { assert.Equal(t, 1, orderedmap.Len(doc.GetExtensions())) } +func TestCreateDocument_DeprecatedWrapper(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: wrapper + version: 1.0.0 +paths: {}`) + + info, err := datamodel.ExtractSpecInfo(spec) + assert.NoError(t, err) + + doc, err := CreateDocument(info) + assert.NoError(t, err) + assert.NotNil(t, doc) + assert.Equal(t, "3.1.0", doc.Version.Value) + assert.Equal(t, "wrapper", doc.Info.Value.Title.Value) +} + func TestCreateDocumentHash(t *testing.T) { // Clear hash cache to ensure deterministic results in concurrent test environments low.ClearHashCache() @@ -505,6 +533,260 @@ func TestCreateDocument_Tags(t *testing.T) { assert.Equal(t, 0, orderedmap.Len(doc.Tags.Value[1].Value.Extensions)) } +func TestCreateDocument_Servers_SkipsNonMapEntries(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - no-thanks + - url: https://api.example.com + description: primary +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + document, err := CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{}) + require.NoError(t, err) + require.NotNil(t, document.Servers.Value) + require.Len(t, document.Servers.Value, 1) + assert.Equal(t, "https://api.example.com", document.Servers.Value[0].Value.URL.Value) +} + +func TestCreateDocument_Servers_NonArrayIgnored(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: nope +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + document, err := CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{}) + require.NoError(t, err) + assert.Nil(t, document.Servers.Value) +} + +func TestCreateDocument_Tags_SkipsNonMapEntries(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +tags: + - nope + - name: burgers + description: burger operations +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + document, err := CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{}) + require.NoError(t, err) + require.NotNil(t, document.Tags.Value) + require.Len(t, document.Tags.Value, 1) + assert.Equal(t, "burgers", document.Tags.Value[0].Value.Name.Value) +} + +func TestExtractServers_AllEntriesSkipped(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - nope + - still-nope +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + doc := &Document{} + err = extractServers(context.Background(), info.RootNode.Content[0], collectDocumentTopLevelNodes(info.RootNode.Content[0]), doc, nil) + require.NoError(t, err) + assert.Nil(t, doc.Servers.Value) +} + +func TestExtractServers_NonArrayIgnored(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: nope +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + doc := &Document{} + err = extractServers(context.Background(), info.RootNode.Content[0], collectDocumentTopLevelNodes(info.RootNode.Content[0]), doc, nil) + require.NoError(t, err) + assert.Nil(t, doc.Servers.Value) +} + +func TestExtractTags_AllEntriesSkipped(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +tags: + - nope + - still-nope +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + doc := &Document{} + err = extractTags(context.Background(), info.RootNode.Content[0], collectDocumentTopLevelNodes(info.RootNode.Content[0]), doc, nil) + require.NoError(t, err) + assert.Nil(t, doc.Tags.Value) +} + +func TestExtractTags_NonArrayIgnored(t *testing.T) { + yml := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +tags: nope +paths: {}` + + info, err := datamodel.ExtractSpecInfo([]byte(yml)) + require.NoError(t, err) + + doc := &Document{} + err = extractTags(context.Background(), info.RootNode.Content[0], collectDocumentTopLevelNodes(info.RootNode.Content[0]), doc, nil) + require.NoError(t, err) + assert.Nil(t, doc.Tags.Value) +} + +func TestCollectDocumentTopLevelNodes_MergeRoot(t *testing.T) { + yml := ` +base: &base + servers: + - url: https://example.com +<<: *base +openapi: 3.1.0 +info: + title: merged + version: 1.0.0 +paths: {}` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &root)) + nodes := collectDocumentTopLevelNodes(root.Content[0]) + if assert.NotNil(t, nodes.servers.key) && assert.NotNil(t, nodes.servers.value) { + assert.Equal(t, ServersLabel, nodes.servers.key.Value) + assert.True(t, utils.IsNodeArray(nodes.servers.value)) + } +} + +func TestCollectDocumentTopLevelNodes_NilRoot(t *testing.T) { + nodes := collectDocumentTopLevelNodes(nil) + assert.Nil(t, nodes.version.value) + assert.Nil(t, nodes.info.value) + assert.Nil(t, nodes.paths.value) +} + +func TestCollectDocumentTopLevelNodes_AllKnownLabels_FirstWins(t *testing.T) { + yml := `openapi: 3.1.0 +openapi: 9.9.9 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +$self: https://example.com/openapi.yaml +info: + title: Test + version: 1.0.0 +servers: + - url: https://first.example.com +servers: + - url: https://second.example.com +tags: [] +components: {} +security: [] +externalDocs: + url: https://docs.example.com +paths: {} +webhooks: {}` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(yml), &root)) + nodes := collectDocumentTopLevelNodes(root.Content[0]) + require.NotNil(t, nodes.version.value) + require.NotNil(t, nodes.jsonSchemaDialect.value) + require.NotNil(t, nodes.self.value) + require.NotNil(t, nodes.info.value) + require.NotNil(t, nodes.servers.value) + require.NotNil(t, nodes.tags.value) + require.NotNil(t, nodes.components.value) + require.NotNil(t, nodes.security.value) + require.NotNil(t, nodes.externalDocs.value) + require.NotNil(t, nodes.paths.value) + require.NotNil(t, nodes.webhooks.value) + assert.Equal(t, "3.1.0", nodes.version.value.Value) + if assert.Len(t, nodes.servers.value.Content, 1) { + assert.Equal(t, "https://first.example.com", nodes.servers.value.Content[0].Content[1].Value) + } +} + +func TestCollectDocumentTopLevelNodes_FirstEntryMerge(t *testing.T) { + serverURL := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "https://merged.example.com"} + serverMap := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "url"}, + serverURL, + }, + } + mergedServers := &yaml.Node{ + Kind: yaml.SequenceNode, + Tag: "!!seq", + Content: []*yaml.Node{ + serverMap, + }, + } + mergedMap := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: ServersLabel}, + mergedServers, + }, + } + root := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!merge", Value: "<<"}, + mergedMap, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: OpenAPILabel}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "3.1.0"}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: base.InfoLabel}, + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "title"}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "merged"}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "version"}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "1.0.0"}, + }, + }, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: PathsLabel}, + {Kind: yaml.MappingNode, Tag: "!!map"}, + }, + } + + nodes := collectDocumentTopLevelNodes(root) + require.NotNil(t, nodes.servers.value) + if assert.Len(t, nodes.servers.value.Content, 1) { + assert.Equal(t, "https://merged.example.com", nodes.servers.value.Content[0].Content[1].Value) + } +} + func TestCreateDocument_Paths(t *testing.T) { initTest() assert.Equal(t, 5, orderedmap.Len(doc.Paths.Value.PathItems)) diff --git a/datamodel/low/v3/encoding.go b/datamodel/low/v3/encoding.go index 5c3b46a25..8f1d1dd73 100644 --- a/datamodel/low/v3/encoding.go +++ b/datamodel/low/v3/encoding.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -7,6 +7,7 @@ import ( "context" "fmt" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -27,6 +28,8 @@ type Encoding struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -85,8 +88,15 @@ func (en *Encoding) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in root = utils.NodeAlias(root) en.RootNode = root utils.CheckForMergeNodes(root) - en.Nodes = low.ExtractNodes(ctx, root) - en.Reference = new(low.Reference) + en.nodeStore = sync.Map{} + en.Nodes = &en.nodeStore + if len(root.Content) > 0 { + en.NodeMap.ExtractNodes(root, false) + } else { + en.AddNode(root.Line, root) + } + en.reference = low.Reference{} + en.Reference = &en.reference en.index = idx en.context = ctx diff --git a/datamodel/low/v3/encoding_test.go b/datamodel/low/v3/encoding_test.go index f898b94d4..c2abdb5f9 100644 --- a/datamodel/low/v3/encoding_test.go +++ b/datamodel/low/v3/encoding_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -104,3 +104,19 @@ style: post modern // hash assert.Equal(t, n.Hash(), n2.Hash()) } + +func TestEncoding_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n Encoding + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/v3/header.go b/datamodel/low/v3/header.go index 2acbc1b3d..02929e25b 100644 --- a/datamodel/low/v3/header.go +++ b/datamodel/low/v3/header.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -7,6 +7,7 @@ import ( "context" "fmt" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -35,6 +36,8 @@ type Header struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -127,14 +130,21 @@ func (h *Header) Hash() uint64 { // Build will extract extensions, examples, schema and content/media types from node. func (h *Header) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { h.KeyNode = keyNode - h.Reference = new(low.Reference) + h.reference = low.Reference{} + h.Reference = &h.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { h.SetReference(ref, root) } root = utils.NodeAlias(root) h.RootNode = root utils.CheckForMergeNodes(root) - h.Nodes = low.ExtractNodes(ctx, root) + h.nodeStore = sync.Map{} + h.Nodes = &h.nodeStore + if len(root.Content) > 0 { + h.NodeMap.ExtractNodes(root, false) + } else { + h.AddNode(root.Line, root) + } h.Extensions = low.ExtractExtensions(root) h.context = ctx h.index = idx @@ -149,11 +159,11 @@ func (h *Header) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index KeyNode: expLabel, } h.Nodes.Store(expLabel.Line, expLabel) - m := low.ExtractNodes(ctx, expNode) - m.Range(func(key, value any) bool { - h.Nodes.Store(key, value) - return true - }) + if len(expNode.Content) > 0 { + h.NodeMap.ExtractNodes(expNode, false) + } else { + h.AddNode(expNode.Line, expNode) + } } // handle examples if set. diff --git a/datamodel/low/v3/header_test.go b/datamodel/low/v3/header_test.go index 1cbfa8efb..6aa8a2aa6 100644 --- a/datamodel/low/v3/header_test.go +++ b/datamodel/low/v3/header_test.go @@ -175,6 +175,40 @@ func TestHeader_Build_Fail_Content(t *testing.T) { assert.Error(t, err) } +func TestHeader_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var h Header + err := low.BuildModel(scalar.Content[0], &h) + assert.NoError(t, err) + + err = h.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := h.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + +func TestHeader_Build_ScalarExampleNode(t *testing.T) { + yml := `example: hello` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var h Header + err := low.BuildModel(idxNode.Content[0], &h) + assert.NoError(t, err) + + err = h.Build(context.Background(), nil, idxNode.Content[0], nil) + assert.NoError(t, err) + assert.Equal(t, "hello", h.Example.Value.Value) + + nodes := h.GetNodes() + assert.NotEmpty(t, nodes[h.Example.Value.Line]) +} + func TestEncoding_Hash_n_Grab(t *testing.T) { yml := `description: heady required: true diff --git a/datamodel/low/v3/link.go b/datamodel/low/v3/link.go index f7e3cd45c..5e262c989 100644 --- a/datamodel/low/v3/link.go +++ b/datamodel/low/v3/link.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -6,6 +6,7 @@ package v3 import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -38,6 +39,8 @@ type Link struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -80,14 +83,21 @@ func (l *Link) GetKeyNode() *yaml.Node { // Build will extract extensions and servers from the node. func (l *Link) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { l.KeyNode = keyNode - l.Reference = new(low.Reference) + l.reference = low.Reference{} + l.Reference = &l.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { l.SetReference(ref, root) } root = utils.NodeAlias(root) l.RootNode = root utils.CheckForMergeNodes(root) - l.Nodes = low.ExtractNodes(ctx, root) + l.nodeStore = sync.Map{} + l.Nodes = &l.nodeStore + if len(root.Content) > 0 { + l.NodeMap.ExtractNodes(root, false) + } else { + l.AddNode(root.Line, root) + } l.Extensions = low.ExtractExtensions(root) l.index = idx l.context = ctx diff --git a/datamodel/low/v3/media_type.go b/datamodel/low/v3/media_type.go index 65dd4ef00..122e027d5 100644 --- a/datamodel/low/v3/media_type.go +++ b/datamodel/low/v3/media_type.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -7,6 +7,7 @@ import ( "context" "hash/maphash" "slices" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -32,6 +33,8 @@ type MediaType struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -87,8 +90,15 @@ func (mt *MediaType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i root = utils.NodeAlias(root) mt.RootNode = root utils.CheckForMergeNodes(root) - mt.Reference = new(low.Reference) - mt.Nodes = low.ExtractNodes(ctx, root) + mt.reference = low.Reference{} + mt.Reference = &mt.reference + mt.nodeStore = sync.Map{} + mt.Nodes = &mt.nodeStore + if len(root.Content) > 0 { + mt.NodeMap.ExtractNodes(root, false) + } else { + mt.AddNode(root.Line, root) + } mt.Extensions = low.ExtractExtensions(root) mt.index = idx mt.context = ctx @@ -100,11 +110,7 @@ func (mt *MediaType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i if expNode != nil { mt.Example = low.NodeReference[*yaml.Node]{Value: expNode, KeyNode: expLabel, ValueNode: expNode} mt.Nodes.Store(expLabel.Line, expLabel) - m := low.ExtractNodesRecursive(ctx, expNode) - m.Range(func(key, value any) bool { - mt.Nodes.Store(key, value) - return true - }) + low.MergeRecursiveNodesIfLineAbsent(mt.Nodes, expNode) } // handle schema diff --git a/datamodel/low/v3/oauth_flows.go b/datamodel/low/v3/oauth_flows.go index 67b46e3af..90dd3456c 100644 --- a/datamodel/low/v3/oauth_flows.go +++ b/datamodel/low/v3/oauth_flows.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -7,6 +7,7 @@ import ( "context" "fmt" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -28,6 +29,8 @@ type OAuthFlows struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -68,8 +71,15 @@ func (o *OAuthFlows) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i root = utils.NodeAlias(root) o.RootNode = root utils.CheckForMergeNodes(root) - o.Reference = new(low.Reference) - o.Nodes = low.ExtractNodes(ctx, root) + o.reference = low.Reference{} + o.Reference = &o.reference + o.nodeStore = sync.Map{} + o.Nodes = &o.nodeStore + if len(root.Content) > 0 { + o.NodeMap.ExtractNodes(root, false) + } else { + o.AddNode(root.Line, root) + } o.Extensions = low.ExtractExtensions(root) o.index = idx o.context = ctx @@ -149,6 +159,8 @@ type OAuthFlow struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -185,8 +197,15 @@ func (o *OAuthFlow) GetRootNode() *yaml.Node { // Build will extract extensions from the node. func (o *OAuthFlow) Build(ctx context.Context, _, root *yaml.Node, idx *index.SpecIndex) error { - o.Reference = new(low.Reference) - o.Nodes = low.ExtractNodes(ctx, root) + o.reference = low.Reference{} + o.Reference = &o.reference + o.nodeStore = sync.Map{} + o.Nodes = &o.nodeStore + if len(root.Content) > 0 { + o.NodeMap.ExtractNodes(root, false) + } else { + o.AddNode(root.Line, root) + } o.Extensions = low.ExtractExtensions(root) o.index = idx o.context = ctx diff --git a/datamodel/low/v3/oauth_flows_test.go b/datamodel/low/v3/oauth_flows_test.go index 558e62594..97346710f 100644 --- a/datamodel/low/v3/oauth_flows_test.go +++ b/datamodel/low/v3/oauth_flows_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -259,6 +259,38 @@ func TestOAuthFlows_DeviceFlow(t *testing.T) { } } +func TestOAuthFlows_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var flows OAuthFlows + err := low.BuildModel(scalar.Content[0], &flows) + assert.NoError(t, err) + + err = flows.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := flows.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + +func TestOAuthFlow_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var flow OAuthFlow + err := low.BuildModel(scalar.Content[0], &flow) + assert.NoError(t, err) + + err = flow.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := flow.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + func TestOAuthFlows_Hash(t *testing.T) { yml := `implicit: authorizationUrl: https://pb33f.io/auth diff --git a/datamodel/low/v3/operation.go b/datamodel/low/v3/operation.go index ba5dbf5d7..e54657d6d 100644 --- a/datamodel/low/v3/operation.go +++ b/datamodel/low/v3/operation.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -7,6 +7,7 @@ import ( "context" "hash/maphash" "sort" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -39,6 +40,8 @@ type Operation struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -84,11 +87,18 @@ func (o *Operation) GetKeyNode() *yaml.Node { // Build will extract external docs, parameters, request body, responses, callbacks, security and servers. func (o *Operation) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { o.KeyNode = keyNode - o.RootNode = root root = utils.NodeAlias(root) + o.RootNode = root utils.CheckForMergeNodes(root) - o.Reference = new(low.Reference) - o.Nodes = low.ExtractNodes(ctx, root) + o.reference = low.Reference{} + o.Reference = &o.reference + o.nodeStore = sync.Map{} + o.Nodes = &o.nodeStore + if len(root.Content) > 0 { + o.NodeMap.ExtractNodes(root, false) + } else { + o.AddNode(root.Line, root) + } o.Extensions = low.ExtractExtensions(root) o.index = idx o.context = ctx @@ -126,11 +136,7 @@ func (o *Operation) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in k, v := utils.FindKeyNode(TagsLabel, root.Content) if k != nil && v != nil { o.Nodes.Store(k.Line, k) - nm := low.ExtractNodesRecursive(ctx, v) - nm.Range(func(key, value interface{}) bool { - o.Nodes.Store(key, value) - return true - }) + low.MergeRecursiveNodesIfLineAbsent(o.Nodes, v) } // extract responses @@ -175,9 +181,9 @@ func (o *Operation) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in // if security is set, but no requirements are defined. // https://github.com/pb33f/libopenapi/issues/111 - if sln != nil && len(svn.Content) == 0 && sec == nil { + if sln != nil && len(svn.Content) == 0 && len(sec) == 0 { o.Security = low.NodeReference[[]low.ValueReference[*base.SecurityRequirement]]{ - Value: []low.ValueReference[*base.SecurityRequirement]{}, // empty + Value: []low.ValueReference[*base.SecurityRequirement]{}, KeyNode: sln, ValueNode: svn, } diff --git a/datamodel/low/v3/operation_test.go b/datamodel/low/v3/operation_test.go index 03cf9ea8b..ee75b8696 100644 --- a/datamodel/low/v3/operation_test.go +++ b/datamodel/low/v3/operation_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -81,6 +81,20 @@ servers: assert.NotNil(t, n.GetRequestBody()) } +func TestOperation_Build_ScalarRoot(t *testing.T) { + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte("nope"), &idxNode) + + var n Operation + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), idxNode.Content[0], idxNode.Content[0], nil) + assert.NoError(t, err) + assert.NotNil(t, n.GetRootNode()) + assert.NotNil(t, n.GetKeyNode()) +} + func TestOperation_Build_FailDocs(t *testing.T) { yml := `externalDocs: $ref: #borked` diff --git a/datamodel/low/v3/parameter.go b/datamodel/low/v3/parameter.go index 05a925b2c..8cf942e28 100644 --- a/datamodel/low/v3/parameter.go +++ b/datamodel/low/v3/parameter.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -8,6 +8,7 @@ import ( "fmt" "hash/maphash" "slices" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -40,6 +41,8 @@ type Parameter struct { Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -86,7 +89,8 @@ func (p *Parameter) GetExtensions() *orderedmap.Map[low.KeyReference[string], lo // Build will extract examples, extensions and content/media types. func (p *Parameter) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { - p.Reference = new(low.Reference) + p.reference = low.Reference{} + p.Reference = &p.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { p.SetReference(ref, root) } @@ -94,7 +98,13 @@ func (p *Parameter) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in p.KeyNode = keyNode p.RootNode = root utils.CheckForMergeNodes(root) - p.Nodes = low.ExtractNodes(ctx, root) + p.nodeStore = sync.Map{} + p.Nodes = &p.nodeStore + if len(root.Content) > 0 { + p.NodeMap.ExtractNodes(root, false) + } else { + p.AddNode(root.Line, root) + } p.Extensions = low.ExtractExtensions(root) p.index = idx p.context = ctx diff --git a/datamodel/low/v3/path_item.go b/datamodel/low/v3/path_item.go index c1d918b1d..5d7146aac 100644 --- a/datamodel/low/v3/path_item.go +++ b/datamodel/low/v3/path_item.go @@ -1,4 +1,4 @@ -// Copyright 2022-2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -9,6 +9,7 @@ import ( "hash/maphash" "sort" "strings" + "sync" "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/low" @@ -44,6 +45,8 @@ type PathItem struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -194,7 +197,8 @@ func (p *PathItem) GetExtensions() *orderedmap.Map[low.KeyReference[string], low // Build extracts extensions, parameters, servers and each http method defined. // everything is extracted asynchronously for speed. func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { - p.Reference = new(low.Reference) + p.reference = low.Reference{} + p.Reference = &p.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { p.SetReference(ref, root) } @@ -202,7 +206,13 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind p.KeyNode = keyNode p.RootNode = root utils.CheckForMergeNodes(root) - p.Nodes = low.ExtractNodes(ctx, root) + p.nodeStore = sync.Map{} + p.Nodes = &p.nodeStore + if len(root.Content) > 0 { + p.NodeMap.ExtractNodes(root, false) + } else { + p.AddNode(root.Line, root) + } p.Extensions = low.ExtractExtensions(root) p.index = idx p.context = ctx @@ -211,7 +221,7 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind skip := false var currentNode *yaml.Node - var ops []low.NodeReference[*Operation] + ops := make([]low.NodeReference[*Operation], 0, len(root.Content)/2) var additionalOps *orderedmap.Map[low.KeyReference[string], low.NodeReference[*Operation]] // extract parameters @@ -231,7 +241,7 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind _, ln, vn = utils.FindKeyNodeFullTop(ServersLabel, root.Content) if vn != nil { if utils.IsNodeArray(vn) { - var servers []low.ValueReference[*Server] + servers := make([]low.ValueReference[*Server], 0, len(vn.Content)) for _, srvN := range vn.Content { if utils.IsNodeMap(srvN) { srvr := new(Server) @@ -422,7 +432,7 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind // assign additionalOperations if any were found if additionalOps != nil && additionalOps.Len() > 0 { - var extrOps []low.NodeReference[*Operation] + extrOps := make([]low.NodeReference[*Operation], 0, additionalOps.Len()) // build out each additional operation for _, appVal := range additionalOps.FromOldest() { extrOps = append(extrOps, appVal) diff --git a/datamodel/low/v3/path_item_test.go b/datamodel/low/v3/path_item_test.go index bb840da36..6bc6693b7 100644 --- a/datamodel/low/v3/path_item_test.go +++ b/datamodel/low/v3/path_item_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -89,6 +89,18 @@ summary: it's another path item` assert.NotNil(t, n.GetIndex()) } +func TestPathItem_Build_ScalarRoot(t *testing.T) { + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte("nope"), &idxNode) + + var n PathItem + _ = low.BuildModel(idxNode.Content[0], &n) + err := n.Build(context.Background(), idxNode.Content[0], idxNode.Content[0], nil) + assert.NoError(t, err) + assert.NotNil(t, n.GetRootNode()) + assert.NotNil(t, n.GetKeyNode()) +} + // https://github.com/pb33f/libopenapi/issues/388 func TestPathItem_CheckExtensionWithParametersValue_NoPanic(t *testing.T) { yml := `x-user_extension: parameters diff --git a/datamodel/low/v3/paths.go b/datamodel/low/v3/paths.go index 141db89be..d8828dd4f 100644 --- a/datamodel/low/v3/paths.go +++ b/datamodel/low/v3/paths.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -30,6 +30,8 @@ type Paths struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -93,8 +95,13 @@ func (p *Paths) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index. p.KeyNode = keyNode p.RootNode = root utils.CheckForMergeNodes(root) - p.Reference = new(low.Reference) - p.Nodes = low.ExtractNodes(ctx, keyNode) + p.reference = low.Reference{} + p.Reference = &p.reference + p.nodeStore = sync.Map{} + p.Nodes = &p.nodeStore + if keyNode != nil { + p.AddNode(keyNode.Line, keyNode) + } p.Extensions = low.ExtractExtensions(root) p.index = idx p.context = ctx @@ -132,7 +139,6 @@ func (p *Paths) Hash() uint64 { } func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) (*orderedmap.Map[low.KeyReference[string], low.ValueReference[*PathItem]], error) { - // Translate YAML nodes to pathsMap using `TranslatePipeline`. type buildResult struct { key low.KeyReference[string] value low.ValueReference[*PathItem] @@ -141,63 +147,37 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn currentNode *yaml.Node pathNode *yaml.Node } + pathsMap := orderedmap.New[low.KeyReference[string], low.ValueReference[*PathItem]]() - in := make(chan buildInput) - out := make(chan buildResult) - done := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(2) // input and output goroutines. - // TranslatePipeline input. - go func() { - defer func() { - close(in) - wg.Done() - }() - skip := false - var currentNode *yaml.Node - if root != nil { - for i, pathNode := range root.Content { - if len(pathNode.Value) >= 2 && (pathNode.Value[0] == 'x' || pathNode.Value[0] == 'X') && pathNode.Value[1] == '-' { - skip = true - continue - } - if skip { - skip = false - continue - } - if i%2 == 0 { - currentNode = pathNode - continue - } + if root == nil { + return pathsMap, nil + } - select { - case in <- buildInput{ - currentNode: currentNode, - pathNode: pathNode, - }: - case <-done: - return - } - } + inputs := make([]buildInput, 0, len(root.Content)/2) + skip := false + var currentNode *yaml.Node + for i, pathNode := range root.Content { + if len(pathNode.Value) >= 2 && (pathNode.Value[0] == 'x' || pathNode.Value[0] == 'X') && pathNode.Value[1] == '-' { + skip = true + continue } - }() - - // TranslatePipeline output. - go func() { - for { - result, ok := <-out - if !ok { - break - } - pathsMap.Set(result.key, result.value) + if skip { + skip = false + continue + } + if i%2 == 0 { + currentNode = pathNode + continue } - close(done) - wg.Done() - }() + inputs = append(inputs, buildInput{ + currentNode: currentNode, + pathNode: pathNode, + }) + } - err := datamodel.TranslatePipeline[buildInput, buildResult](in, out, - func(value buildInput) (buildResult, error) { + err := datamodel.TranslateSliceParallel(inputs, + func(_ int, value buildInput) (buildResult, error) { pNode := value.pathNode cNode := value.currentNode @@ -211,10 +191,8 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn if r != nil { pNode = r foundContext = fCtx - if err != nil { - if !idx.AllowCircularReferenceResolving() { - return buildResult{}, fmt.Errorf("path item build failed: %s", err.Error()) - } + if err != nil && !idx.AllowCircularReferenceResolving() { + return buildResult{}, fmt.Errorf("path item build failed: %s", err.Error()) } } else { return buildResult{}, fmt.Errorf("path item build failed: cannot find reference: '%s' at line %d, col %d", @@ -230,11 +208,8 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn path.SetReference(refNode.Content[1].Value, refNode) } - if err != nil { - if idx != nil && idx.GetLogger() != nil { - idx.GetLogger().Error(fmt.Sprintf("error building path item: %s", err.Error())) - } - // return buildResult{}, err + if err != nil && idx != nil && idx.GetLogger() != nil { + idx.GetLogger().Error(fmt.Sprintf("error building path item: %s", err.Error())) } return buildResult{ @@ -248,8 +223,11 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn }, }, nil }, + func(result buildResult) error { + pathsMap.Set(result.key, result.value) + return nil + }, ) - wg.Wait() if err != nil { return nil, err } diff --git a/datamodel/low/v3/paths_test.go b/datamodel/low/v3/paths_test.go index 4bb1f9394..de54671b2 100644 --- a/datamodel/low/v3/paths_test.go +++ b/datamodel/low/v3/paths_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -82,6 +82,33 @@ x-milk: cold` assert.Equal(t, 1, orderedmap.Len(n.GetExtensions())) } +func TestExtractPathItemsMap_NilRoot(t *testing.T) { + pathMap, err := extractPathItemsMap(context.Background(), nil, nil) + assert.NoError(t, err) + assert.NotNil(t, pathMap) + assert.Zero(t, pathMap.Len()) +} + +func TestExtractPathItemsMap_SkipsExtensions(t *testing.T) { + yml := `x-note: ignore +"/some/path": + get: + description: ok` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + pathMap, err := extractPathItemsMap(context.Background(), idxNode.Content[0], index.NewSpecIndex(&idxNode)) + assert.NoError(t, err) + if assert.NotNil(t, pathMap) { + assert.Equal(t, 1, pathMap.Len()) + path := low.FindItemInOrderedMap("/some/path", pathMap) + if assert.NotNil(t, path) { + assert.Equal(t, "ok", path.Value.Get.Value.Description.Value) + } + } +} + func TestPaths_Build_Fail(t *testing.T) { yml := `"/some/path": $ref: $bork` diff --git a/datamodel/low/v3/request_body.go b/datamodel/low/v3/request_body.go index 06b054602..344822e2d 100644 --- a/datamodel/low/v3/request_body.go +++ b/datamodel/low/v3/request_body.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -6,6 +6,7 @@ package v3 import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -25,6 +26,8 @@ type RequestBody struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -67,14 +70,21 @@ func (rb *RequestBody) FindContent(cType string) *low.ValueReference[*MediaType] // Build will extract extensions and MediaType objects from the node. func (rb *RequestBody) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { rb.KeyNode = keyNode - rb.Reference = new(low.Reference) + rb.reference = low.Reference{} + rb.Reference = &rb.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { rb.SetReference(ref, root) } root = utils.NodeAlias(root) rb.RootNode = root utils.CheckForMergeNodes(root) - rb.Nodes = low.ExtractNodes(ctx, root) + rb.nodeStore = sync.Map{} + rb.Nodes = &rb.nodeStore + if len(root.Content) > 0 { + rb.NodeMap.ExtractNodes(root, false) + } else { + rb.AddNode(root.Line, root) + } rb.Extensions = low.ExtractExtensions(root) rb.index = idx rb.context = ctx diff --git a/datamodel/low/v3/request_body_test.go b/datamodel/low/v3/request_body_test.go index 7899b438f..7a53db7e0 100644 --- a/datamodel/low/v3/request_body_test.go +++ b/datamodel/low/v3/request_body_test.go @@ -156,3 +156,19 @@ func TestRequestBody_TopLevelExampleExtraction(t *testing.T) { schemaLevelExample := getExample(schemaLevelYml) assert.Equal(t, "", schemaLevelExample) } + +func TestRequestBody_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var rb RequestBody + err := low.BuildModel(scalar.Content[0], &rb) + assert.NoError(t, err) + + err = rb.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := rb.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/v3/response.go b/datamodel/low/v3/response.go index 72eca09b0..15a94d46c 100644 --- a/datamodel/low/v3/response.go +++ b/datamodel/low/v3/response.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -6,6 +6,7 @@ package v3 import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -30,6 +31,8 @@ type Response struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -82,14 +85,21 @@ func (r *Response) FindLink(hType string) *low.ValueReference[*Link] { // Build will extract headers, extensions, content and links from node. func (r *Response) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { r.KeyNode = keyNode - r.Reference = new(low.Reference) + r.reference = low.Reference{} + r.Reference = &r.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { r.SetReference(ref, root) } root = utils.NodeAlias(root) r.RootNode = root utils.CheckForMergeNodes(root) - r.Nodes = low.ExtractNodes(ctx, root) + r.nodeStore = sync.Map{} + r.Nodes = &r.nodeStore + if len(root.Content) > 0 { + r.NodeMap.ExtractNodes(root, false) + } else { + r.AddNode(root.Line, root) + } r.Extensions = low.ExtractExtensions(root) r.index = idx r.context = ctx diff --git a/datamodel/low/v3/response_test.go b/datamodel/low/v3/response_test.go index c902101b3..5539913c3 100644 --- a/datamodel/low/v3/response_test.go +++ b/datamodel/low/v3/response_test.go @@ -135,6 +135,22 @@ content: assert.NotEqual(t, hash1, hash2, "Hash should change when summary changes") } +func TestResponse_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var r Response + err := low.BuildModel(scalar.Content[0], &r) + assert.NoError(t, err) + + err = r.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := r.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + func TestResponses_NoDefault(t *testing.T) { cleanHashCacheForTest(t) yml := `"200": @@ -278,6 +294,23 @@ func TestResponses_Build_FailBadLinks(t *testing.T) { assert.Error(t, err) } +func TestResponses_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n Responses + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, scalar.Content[0], nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "vn node is not a map") + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + func TestResponses_Build_AllowXPrefixHeader(t *testing.T) { cleanHashCacheForTest(t) yml := `"200": diff --git a/datamodel/low/v3/responses.go b/datamodel/low/v3/responses.go index 5a43db462..b7891e9b8 100644 --- a/datamodel/low/v3/responses.go +++ b/datamodel/low/v3/responses.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -8,6 +8,7 @@ import ( "fmt" "hash/maphash" "strings" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -42,6 +43,8 @@ type Responses struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -76,8 +79,15 @@ func (r *Responses) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in r.KeyNode = keyNode root = utils.NodeAlias(root) r.RootNode = root - r.Reference = new(low.Reference) - r.Nodes = low.ExtractNodes(ctx, root) + r.reference = low.Reference{} + r.Reference = &r.reference + r.nodeStore = sync.Map{} + r.Nodes = &r.nodeStore + if len(root.Content) > 0 { + r.NodeMap.ExtractNodes(root, false) + } else { + r.AddNode(root.Line, root) + } r.Extensions = low.ExtractExtensions(root) r.index = idx r.context = ctx diff --git a/datamodel/low/v3/security_scheme.go b/datamodel/low/v3/security_scheme.go index 42788eed0..66185deb5 100644 --- a/datamodel/low/v3/security_scheme.go +++ b/datamodel/low/v3/security_scheme.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -6,6 +6,7 @@ package v3 import ( "context" "hash/maphash" + "sync" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -40,6 +41,8 @@ type SecurityScheme struct { RootNode *yaml.Node index *index.SpecIndex context context.Context + nodeStore sync.Map + reference low.Reference *low.Reference low.NodeMap } @@ -77,14 +80,21 @@ func (ss *SecurityScheme) GetExtensions() *orderedmap.Map[low.KeyReference[strin // Build will extract OAuthFlows and extensions from the node. func (ss *SecurityScheme) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { ss.KeyNode = keyNode - ss.Reference = new(low.Reference) + ss.reference = low.Reference{} + ss.Reference = &ss.reference if ok, _, ref := utils.IsNodeRefValue(root); ok { ss.SetReference(ref, root) } root = utils.NodeAlias(root) ss.RootNode = root utils.CheckForMergeNodes(root) - ss.Nodes = low.ExtractNodes(ctx, root) + ss.nodeStore = sync.Map{} + ss.Nodes = &ss.nodeStore + if len(root.Content) > 0 { + ss.NodeMap.ExtractNodes(root, false) + } else { + ss.AddNode(root.Line, root) + } ss.Extensions = low.ExtractExtensions(root) ss.index = idx ss.context = ctx diff --git a/datamodel/low/v3/security_scheme_test.go b/datamodel/low/v3/security_scheme_test.go index 1cebf7813..db2d9fa0c 100644 --- a/datamodel/low/v3/security_scheme_test.go +++ b/datamodel/low/v3/security_scheme_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 @@ -143,3 +143,19 @@ flows: hash3 := n.Hash() assert.NotEqual(t, hash1, hash3) } + +func TestSecurityScheme_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var scheme SecurityScheme + err := low.BuildModel(scalar.Content[0], &scheme) + assert.NoError(t, err) + + err = scheme.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := scheme.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} diff --git a/datamodel/low/v3/server.go b/datamodel/low/v3/server.go index 5b45b1769..0c7dc14cd 100644 --- a/datamodel/low/v3/server.go +++ b/datamodel/low/v3/server.go @@ -1,4 +1,4 @@ -// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package v3 diff --git a/datamodel/low/v3/server_test.go b/datamodel/low/v3/server_test.go index f90f60782..774f1c267 100644 --- a/datamodel/low/v3/server_test.go +++ b/datamodel/low/v3/server_test.go @@ -132,6 +132,38 @@ description: high quality software for developers.` assert.Equal(t, 0, orderedmap.Len(n.Variables.Value)) } +func TestServer_Build_ScalarRoot(t *testing.T) { + var scalar yaml.Node + _ = yaml.Unmarshal([]byte("hello"), &scalar) + + var n Server + err := low.BuildModel(scalar.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, scalar.Content[0], nil) + assert.NoError(t, err) + + nodes := n.GetNodes() + assert.Len(t, nodes[scalar.Content[0].Line], 1) + assert.Equal(t, "hello", nodes[scalar.Content[0].Line][0].Value) +} + +func TestServer_Build_VariablesNotMap(t *testing.T) { + yml := `url: https://pb33f.io +variables: no` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + var n Server + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, idxNode.Content[0], nil) + assert.NoError(t, err) + assert.Nil(t, n.Variables.Value) +} + func TestServer_Name_OpenAPI32(t *testing.T) { yml := `name: Production Server url: https://api.example.com diff --git a/utils/jsonpath_fastpath.go b/utils/jsonpath_fastpath.go new file mode 100644 index 000000000..e39daf4f3 --- /dev/null +++ b/utils/jsonpath_fastpath.go @@ -0,0 +1,142 @@ +// Copyright 2023-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package utils + +import "go.yaml.in/yaml/v4" + +type simpleJSONPathStepKind uint8 + +const ( + simpleJSONPathProperty simpleJSONPathStepKind = iota + simpleJSONPathIndex +) + +type simpleJSONPathStep struct { + kind simpleJSONPathStepKind + property string + index int +} + +func findNodesWithoutDeserializingFastPath(node *yaml.Node, jsonPath string) ([]*yaml.Node, bool) { + steps, ok := parseSimpleJSONPath(jsonPath) + if !ok { + return nil, false + } + + current := NodeAlias(node) + if current == nil { + return nil, true + } + if current.Kind == yaml.DocumentNode && len(current.Content) > 0 { + current = NodeAlias(current.Content[0]) + } + + for _, step := range steps { + switch step.kind { + case simpleJSONPathProperty: + current = navigateJSONPathProperty(current, step.property) + case simpleJSONPathIndex: + current = navigateJSONPathIndex(current, step.index) + } + if current == nil { + return nil, true + } + } + + return []*yaml.Node{current}, true +} + +func parseSimpleJSONPath(path string) ([]simpleJSONPathStep, bool) { + if path == "" || path[0] != '$' { + return nil, false + } + + steps := make([]simpleJSONPathStep, 0, 8) + for i := 1; i < len(path); { + switch path[i] { + case '.': + i++ + if i >= len(path) { + return nil, false + } + start := i + for i < len(path) && path[i] != '.' && path[i] != '[' { + switch path[i] { + case '*', '?', '(', ')': + return nil, false + } + i++ + } + if start == i { + return nil, false + } + token := path[start:i] + if index, ok := parseSmallUint(token); ok { + steps = append(steps, simpleJSONPathStep{kind: simpleJSONPathIndex, index: index}) + } else { + steps = append(steps, simpleJSONPathStep{kind: simpleJSONPathProperty, property: token}) + } + case '[': + if i+1 >= len(path) { + return nil, false + } + if path[i+1] == '\'' { + i += 2 + start := i + for i < len(path) && path[i] != '\'' { + i++ + } + if i >= len(path) || i+1 >= len(path) || path[i+1] != ']' { + return nil, false + } + steps = append(steps, simpleJSONPathStep{ + kind: simpleJSONPathProperty, + property: path[start:i], + }) + i += 2 + continue + } + + i++ + start := i + for i < len(path) && path[i] != ']' { + if path[i] < '0' || path[i] > '9' { + return nil, false + } + i++ + } + if i >= len(path) || start == i { + return nil, false + } + index, _ := parseSmallUint(path[start:i]) + steps = append(steps, simpleJSONPathStep{kind: simpleJSONPathIndex, index: index}) + i++ + default: + return nil, false + } + } + + return steps, true +} + +func navigateJSONPathProperty(node *yaml.Node, property string) *yaml.Node { + current := NodeAlias(node) + if !IsNodeMap(current) { + return nil + } + for i := 0; i < len(current.Content)-1; i += 2 { + if current.Content[i].Value == property { + return NodeAlias(current.Content[i+1]) + } + } + return nil +} + +func navigateJSONPathIndex(node *yaml.Node, index int) *yaml.Node { + current := NodeAlias(node) + if !IsNodeArray(current) || index < 0 || index >= len(current.Content) { + return nil + } + return NodeAlias(current.Content[index]) +} diff --git a/utils/jsonpath_fastpath_test.go b/utils/jsonpath_fastpath_test.go new file mode 100644 index 000000000..9bcee0e5a --- /dev/null +++ b/utils/jsonpath_fastpath_test.go @@ -0,0 +1,129 @@ +// Copyright 2023-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + "time" + + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestFindNodesWithoutDeserializingWithOptions_FastPathBypassesJSONPathEngine(t *testing.T) { + root, _ := FindNodes(getPetstore(), "$") + + original := jsonPathQuery + called := false + jsonPathQuery = func(path *jsonpath.JSONPath, node *yaml.Node) []*yaml.Node { + called = true + return original(path, node) + } + defer func() { + jsonPathQuery = original + }() + + nodes, err := FindNodesWithoutDeserializingWithOptions(root[0], "$.info.contact", JSONPathLookupOptions{}) + require.NoError(t, err) + require.Len(t, nodes, 1) + assert.False(t, called) +} + +func TestFindNodesWithoutDeserializingWithOptions_FastPathSupportsBracketPropertiesAndIndexes(t *testing.T) { + spec := `paths: + /pet: + get: + parameters: + - name: id + in: path` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &root)) + + nodes, err := FindNodesWithoutDeserializingWithOptions(&root, "$.paths['/pet'].get.parameters[0].name", JSONPathLookupOptions{}) + require.NoError(t, err) + require.Len(t, nodes, 1) + assert.Equal(t, "id", nodes[0].Value) +} + +func TestFindNodesWithoutDeserializingFastPath_Edges(t *testing.T) { + results, handled := findNodesWithoutDeserializingFastPath(nil, "$.info.contact") + assert.True(t, handled) + assert.Nil(t, results) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("items:\n - zero\n"), &root)) + results, handled = findNodesWithoutDeserializingFastPath(&root, "$.items[3]") + assert.True(t, handled) + assert.Nil(t, results) +} + +func TestParseSimpleJSONPath_Edges(t *testing.T) { + validCases := []struct { + path string + stepCount int + lastKind simpleJSONPathStepKind + }{ + {path: "$.items.0", stepCount: 2, lastKind: simpleJSONPathIndex}, + {path: "$['paths'][0]", stepCount: 2, lastKind: simpleJSONPathIndex}, + } + for _, tc := range validCases { + t.Run(tc.path, func(t *testing.T) { + steps, ok := parseSimpleJSONPath(tc.path) + require.True(t, ok) + require.Len(t, steps, tc.stepCount) + assert.Equal(t, tc.lastKind, steps[len(steps)-1].kind) + }) + } + + for _, path := range []string{"", "info.contact", "$.", "$x", "$.info.*", "$[", "$['unterminated", "$[abc]", "$[]"} { + t.Run(path, func(t *testing.T) { + steps, ok := parseSimpleJSONPath(path) + assert.False(t, ok) + assert.Nil(t, steps) + }) + } +} + +func TestJSONPathFastPathNavigationHelpers(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("info:\n contact:\n name: jane\nitems:\n - zero\n"), &root)) + + doc := &root + mapNode := navigateJSONPathProperty(doc.Content[0], "info") + require.NotNil(t, mapNode) + assert.Nil(t, navigateJSONPathProperty(doc.Content[0], "missing")) + + itemsNode := navigateJSONPathProperty(doc.Content[0], "items") + require.NotNil(t, itemsNode) + assert.Nil(t, navigateJSONPathProperty(itemsNode, "info")) + indexNode := navigateJSONPathIndex(itemsNode, 0) + require.NotNil(t, indexNode) + assert.Equal(t, "zero", indexNode.Value) + assert.Nil(t, navigateJSONPathIndex(itemsNode, 9)) + assert.Nil(t, navigateJSONPathIndex(mapNode, 0)) +} + +func TestFindNodesWithoutDeserializingWithOptions_FallbackSuccess(t *testing.T) { + root, _ := FindNodes(getPetstore(), "$") + + original := jsonPathQuery + called := false + jsonPathQuery = func(path *jsonpath.JSONPath, node *yaml.Node) []*yaml.Node { + called = true + return original(path, node) + } + defer func() { + jsonPathQuery = original + }() + + nodes, err := FindNodesWithoutDeserializingWithOptions(root[0], "$..contact", JSONPathLookupOptions{ + Timeout: 100 * time.Millisecond, + }) + require.NoError(t, err) + require.NotEmpty(t, nodes) + assert.True(t, called) +} diff --git a/utils/utils.go b/utils/utils.go index 6fefbb8dd..e8cb317fc 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -192,6 +192,10 @@ func FindNodesWithoutDeserializingWithTimeout(node *yaml.Node, jsonPath string, // Behavior can be customized using JSONPathLookupOptions. func FindNodesWithoutDeserializingWithOptions(node *yaml.Node, jsonPath string, options JSONPathLookupOptions) ([]*yaml.Node, error) { options = normalizeJSONPathLookupOptions(options) + if results, handled := findNodesWithoutDeserializingFastPath(node, jsonPath); handled { + return results, nil + } + path, err := getJSONPathWithOptions(jsonPath, options) if err != nil { return nil, err diff --git a/utils/utils_jsonpath_bench_test.go b/utils/utils_jsonpath_bench_test.go new file mode 100644 index 000000000..d5f543dfc --- /dev/null +++ b/utils/utils_jsonpath_bench_test.go @@ -0,0 +1,67 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func benchmarkPetstoreRootNode(b *testing.B) *yaml.Node { + b.Helper() + + root, err := FindNodes(getPetstore(), "$") + if err != nil { + b.Fatalf("failed to load benchmark root node: %v", err) + } + if len(root) == 0 || root[0] == nil { + b.Fatal("failed to load benchmark root node: no root node found") + } + return root[0] +} + +func BenchmarkFindNodesWithoutDeserializingWithOptions_Default(b *testing.B) { + ClearJSONPathCache() + root := benchmarkPetstoreRootNode(b) + path := "$.info.contact" + + nodes, err := FindNodesWithoutDeserializingWithOptions(root, path, JSONPathLookupOptions{}) + if err != nil || len(nodes) == 0 { + b.Fatalf("benchmark setup failed: len=%d err=%v", len(nodes), err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + nodes, err = FindNodesWithoutDeserializingWithOptions(root, path, JSONPathLookupOptions{}) + if err != nil || len(nodes) == 0 { + b.Fatalf("lookup failed: len=%d err=%v", len(nodes), err) + } + } +} + +func BenchmarkFindNodesWithoutDeserializingWithOptions_LazyDisabled(b *testing.B) { + ClearJSONPathCache() + root := benchmarkPetstoreRootNode(b) + path := "$.info.contact" + lazyDisabled := false + options := JSONPathLookupOptions{LazyContextTracking: &lazyDisabled} + + nodes, err := FindNodesWithoutDeserializingWithOptions(root, path, options) + if err != nil || len(nodes) == 0 { + b.Fatalf("benchmark setup failed: len=%d err=%v", len(nodes), err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + nodes, err = FindNodesWithoutDeserializingWithOptions(root, path, options) + if err != nil || len(nodes) == 0 { + b.Fatalf("lookup failed: len=%d err=%v", len(nodes), err) + } + } +} diff --git a/utils/utils_test.go b/utils/utils_test.go index 717f37a09..b5b295111 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -1730,7 +1730,7 @@ func TestFindNodesWithoutDeserializingWithTimeout_Timeout(t *testing.T) { <-done }() - nodes, err := FindNodesWithoutDeserializingWithTimeout(root[0], "$.info.contact", 1*time.Millisecond) + nodes, err := FindNodesWithoutDeserializingWithTimeout(root[0], "$..contact", 1*time.Millisecond) assert.Nil(t, nodes) assert.ErrorContains(t, err, "timeout exceeded") }