diff --git a/AGENTS.md b/AGENTS.md
index c354ac0e..b98119ab 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,6 +22,7 @@ This repo is a library, not an app. The root package exposes the public entry po
| `renderer/` | Schema/mock sample generation |
| `orderedmap/` | Stable insertion-ordered map wrapper used throughout models/rendering |
| `json/` | YAML-node to ordered JSON conversion |
+| `internal/jsonnode/` | Direct JSON parser building the exact `yaml.Node` tree yaml v4 builds; used for JSON specs, declines to `yaml.Unmarshal` otherwise |
| `tests/` | Cross-package integration and benchmark coverage, especially sibling-ref behavior |
| `test_specs/` | Realistic fixtures and regression specs used across packages |
@@ -61,6 +62,7 @@ This repo is a library, not an app. The root package exposes the public entry po
| `go test ./bundler -run TestBundle` | Target bundler regressions |
| `go test ./what-changed/... -run Test` | Target diff/breaking-rule regressions |
| `go test -bench . ./index ./datamodel/low/... ./what-changed/...` | Run benchmarks in hot paths |
+| `go test -run xxx -bench BenchmarkPipeline -count 6 .` | End-to-end build/render/compare/bundle benchmarks on large specs (compare runs with `benchstat`) |
| `GOCACHE=/tmp/go-build go test ./...` | Useful in restricted sandboxes where default Go build cache is not writable |
## Testing Caveats
diff --git a/MIGRATING.md b/MIGRATING.md
new file mode 100644
index 00000000..b543a67a
--- /dev/null
+++ b/MIGRATING.md
@@ -0,0 +1,177 @@
+# Migrating
+
+This file lists breaking changes to the libopenapi Go API and how to update code for each one.
+
+## Upgrading past v0.40
+
+This release makes two changes to the low-level model API. They don't change parsing, rendering, bundling or
+what-changed output.
+
+You don't need to change anything if your code only uses:
+- the high-level model
+- `GetNodes()`
+- `AddNode()`
+
+### `NodeMap.Nodes` is now a `*low.NodeLines`
+
+Every low-level model embeds `low.NodeMap`, which records the YAML nodes the model was built from, keyed by line
+number. `Nodes` was a `*sync.Map` keyed by `int`. It is now a `*low.NodeLines`. A `NodeLines` records writes as they
+happen and builds its line index the first time it is read. Most models are built and never read, so building a
+document and comparing documents with what-changed allocate less.
+
+| v0.40 | now |
+|---|---|
+| `Nodes *sync.Map` | `Nodes *low.NodeLines` |
+| `Nodes.Range(func(key, value any) bool)` | `Nodes.Range(func(line int, value any) bool)`, visiting lines in ascending order |
+| `Nodes.Load(key any) (any, bool)` | `Nodes.Load(line int) (any, bool)` |
+| `Nodes.Store(key, value any)` | `Nodes.Store(line int, value any)` |
+| `low.NodeMap{Nodes: &sync.Map{}}` | `low.NodeMap{Nodes: &low.NodeLines{}}` (the zero value is ready to use) |
+| `low.ExtractNodes(ctx, root) *sync.Map` | returns `*low.NodeLines` |
+| `low.ExtractNodesRecursive(ctx, root) *sync.Map` | returns `*low.NodeLines` |
+| `low.ExtractExtensionNodes(ctx, extensions, nodes *sync.Map)` | takes `*low.NodeLines` |
+| `low.MergeRecursiveNodesIfLineAbsent(dst *sync.Map, node)` | takes `*low.NodeLines` |
+
+`NodeLines` does not have `sync.Map`'s other methods:
+- `Delete` and `Clear`
+- `LoadOrStore` and `LoadAndDelete`
+- `Swap`, `CompareAndSwap` and `CompareAndDelete`
+
+A line's value is a `*yaml.Node`, or a `[]*yaml.Node` when several nodes share the line, as before.
+
+This program reads the nodes of an `info` object:
+
+```go
+package main
+
+import (
+ "fmt"
+
+ "github.com/pb33f/libopenapi"
+ "go.yaml.in/yaml/v4"
+)
+
+const spec = `openapi: 3.1.0
+info:
+ title: Burger Shop
+ version: 1.0.0
+paths: {}
+`
+
+func main() {
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ if err != nil {
+ panic(err)
+ }
+ model, err := doc.BuildV3Model()
+ if err != nil {
+ panic(err)
+ }
+ info := model.Model.GoLow().Info.Value
+
+ // Range passes each line as an int, in ascending order. A line holds a *yaml.Node, or a
+ // []*yaml.Node when several nodes share it.
+ info.Nodes.Range(func(line int, value any) bool {
+ switch v := value.(type) {
+ case *yaml.Node:
+ fmt.Printf("line %d: %s\n", line, v.Value)
+ case []*yaml.Node:
+ for _, n := range v {
+ fmt.Printf("line %d: %s\n", line, n.Value)
+ }
+ }
+ return true
+ })
+
+ // Load takes the line as an int.
+ if value, ok := info.Nodes.Load(4); ok {
+ fmt.Printf("line 4 holds %d nodes\n", len(value.([]*yaml.Node)))
+ }
+
+ // GetNodes is unchanged.
+ fmt.Printf("GetNodes: %d lines\n", len(info.GetNodes()))
+}
+```
+
+Output:
+
+```
+line 3: title
+line 3: Burger Shop
+line 4: version
+line 4: 1.0.0
+line 4 holds 2 nodes
+GetNodes: 2 lines
+```
+
+### `NodeReference.Context` is removed
+
+`low.NodeReference[T]` had a `Context` field. libopenapi only ever set it on the operations of a `PathItem`:
+- `Get`, `Put`, `Post`, `Delete`, `Options`, `Head`, `Patch`, `Trace` and `Query`
+- the operations in `AdditionalOperations`
+
+On those references it held the context the operation was resolved and built with. On every other reference it
+was nil.
+
+Read that context from the operation instead:
+
+| v0.40 | now |
+|---|---|
+| `pathItem.Get.Context` | `pathItem.Get.Value.GetContext()` |
+
+`GetContext()` returns the context passed to the operation's `Build`, which is the context `Context` held. If an
+operation is a `$ref` to another file, that context carries the other file's index.
+
+If your code sets `Context` on references it creates, keep the context next to the reference in a type of your
+own, for example `struct { Ref low.NodeReference[T]; Ctx context.Context }`.
+
+This program reads the context the `get` operation of `/burgers` was built with:
+
+```go
+package main
+
+import (
+ "fmt"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi/index"
+)
+
+const spec = `openapi: 3.1.0
+info:
+ title: Burger Shop
+ version: 1.0.0
+paths:
+ /burgers:
+ get:
+ operationId: listBurgers
+ responses:
+ '200':
+ description: OK
+`
+
+func main() {
+ doc, err := libopenapi.NewDocument([]byte(spec))
+ if err != nil {
+ panic(err)
+ }
+ model, err := doc.BuildV3Model()
+ if err != nil {
+ panic(err)
+ }
+ pathItem := model.Model.GoLow().Paths.Value.FindPath("/burgers").Value
+
+ // was: ctx := pathItem.Get.Context
+ ctx := pathItem.Get.Value.GetContext()
+
+ idx := ctx.Value(index.FoundIndexKey).(*index.SpecIndex)
+ fmt.Println("operation:", pathItem.Get.Value.OperationId.Value)
+ fmt.Println("built with the operation's index:", idx == pathItem.Get.Value.GetIndex())
+}
+```
+
+Output:
+
+```
+operation: listBurgers
+built with the operation's index: true
+```
diff --git a/README.md b/README.md
index 0bf7d358..07f59508 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ See all the documentation at https://pb33f.io/libopenapi/
- [Parsing Code](https://pb33f.io/libopenapi/parsing-code/)
- [FAQ](https://pb33f.io/libopenapi/faq/)
- [About libopenapi](https://pb33f.io/libopenapi/about/)
+- [Migrating: breaking API changes by version](MIGRATING.md)
### Generating TypeScript models
diff --git a/cache.go b/cache.go
index 432096e8..32efeb3f 100644
--- a/cache.go
+++ b/cache.go
@@ -4,6 +4,7 @@
package libopenapi
import (
+ "github.com/pb33f/libopenapi/datamodel/high"
highbase "github.com/pb33f/libopenapi/datamodel/high/base"
"github.com/pb33f/libopenapi/datamodel/low"
lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
@@ -15,14 +16,15 @@ import (
//
// Calling it is not required to release memory: caches keyed by YAML nodes or model objects hold them weakly,
// so a document is reclaimed as soon as the caller drops it. Use it to force hashes to be recalculated after
-// YAML nodes or low-level models were modified in place, or to empty the string-keyed caches (compiled JSONPath
-// expressions, schema quick hashes and remote content types). It is safe to call while other goroutines parse,
-// build or compare documents.
+// YAML nodes or low-level models were modified in place, or to empty the content-keyed caches (compiled
+// JSONPath expressions, schema quick hashes, encoded values and remote content types). It is safe to call
+// while other goroutines parse, build or compare documents.
func ClearAllCaches() {
low.ClearHashCache() // model and YAML node hashes
lowbase.ClearSchemaQuickHashMap() // SchemaQuickHashMap
index.ClearHashCache() // nodeHashCache
index.ClearContentDetectionCache()
highbase.ClearInlineRenderingTracker()
+ high.ClearEncodeCache()
utils.ClearJSONPathCache()
}
diff --git a/datamodel/high/base/schema_proxy.go b/datamodel/high/base/schema_proxy.go
index 3401c677..e44109e5 100644
--- a/datamodel/high/base/schema_proxy.go
+++ b/datamodel/high/base/schema_proxy.go
@@ -8,7 +8,6 @@ import (
"fmt"
"net/url"
"path/filepath"
- "slices"
"strconv"
"strings"
"sync"
@@ -104,6 +103,34 @@ type InlineRenderContext struct {
referenceNodeTargets sync.Map // authored reference node -> canonical target
preservedReferenceNodes sync.Map // authored reference node -> struct{}
referenceNodeRewrites sync.Map // authored reference node -> root component ref
+ absoluteSpecPaths sync.Map // spec path -> its absolute form, resolved once per render
+ canonicalIdentities sync.Map // reference -> index.CanonicalReferenceIdentity(reference)
+}
+
+// absoluteSpecPath returns a spec path in absolute form, as filepath.Abs does for a relative local path.
+// filepath.Abs queries the working directory, so each path is resolved once per render rather than for
+// every reference rendered.
+func (ctx *InlineRenderContext) absoluteSpecPath(specPath string) string {
+ if filepath.IsAbs(specPath) || strings.HasPrefix(specPath, "http") {
+ return specPath
+ }
+ if cached, ok := ctx.absoluteSpecPaths.Load(specPath); ok {
+ return cached.(string)
+ }
+ abs, _ := filepath.Abs(specPath)
+ ctx.absoluteSpecPaths.Store(specPath, abs)
+ return abs
+}
+
+// canonicalReferenceIdentity memoizes index.CanonicalReferenceIdentity, which parses and cleans the
+// reference, for the circular references compared against every rendered reference.
+func (ctx *InlineRenderContext) canonicalReferenceIdentity(ref string) string {
+ if cached, ok := ctx.canonicalIdentities.Load(ref); ok {
+ return cached.(string)
+ }
+ identity := index.CanonicalReferenceIdentity(ref)
+ ctx.canonicalIdentities.Store(ref, identity)
+ return identity
}
// NewInlineRenderContext creates a new isolated rendering context with default bundle mode.
@@ -1090,90 +1117,11 @@ func (sp *SchemaProxy) marshalYAMLInlineInternal(ctx *InlineRenderContext) (inte
var err error
s, err = sp.BuildSchema()
- if s != nil && s.GoLow() != nil && s.GoLow().Index != nil {
- idx := s.GoLow().Index
-
- // GetCircularReferences hands back the index's own slice, which the resolver grows with
- // append and therefore leaves spare capacity on. clone before extending, or the appends
- // below write into memory shared with every other render using this index.
- circ := slices.Clone(idx.GetCircularReferences())
-
- // extract ignored and safe circular references from rolodex if available, along with the
- // root index circulars. circular references are registered on the rolodex root, but this
- // schema's index is the one owning its resolved content, which for an external $ref is not
- // the root. without that the guard below silently stops firing for referenced schemas.
- if rolodex := idx.GetRolodex(); rolodex != nil {
- if root := rolodex.GetRootIndex(); root != nil && root != idx {
- circ = append(circ, root.GetCircularReferences()...)
- }
- circ = append(circ, rolodex.GetIgnoredCircularReferences()...)
- circ = append(circ, rolodex.GetSafeCircularReferences()...)
- }
-
- cirError := func(str string) error {
- return fmt.Errorf("schema render failure, circular reference: `%s`", str)
- }
-
- for _, c := range circ {
- if sp.IsReference() {
- if c == nil || c.LoopPoint == nil {
- continue
- }
- if ctx.StrictCircularReferenceIdentity {
- target := sp.referenceTargetIdentity(ctx)
- if target == "" || target != index.CanonicalReferenceIdentity(c.LoopPoint.FullDefinition) {
- continue
- }
- node, rewritten, refErr := rewrittenRefNode()
- return sp.circularReferenceResult(ctx, node, rewritten, refErr, cirError(c.LoopPoint.Definition))
- }
- if sp.GetReference() == c.LoopPoint.Definition {
- node, rewritten, refErr := rewrittenRefNode()
- return sp.circularReferenceResult(ctx, node, rewritten, refErr, cirError(c.LoopPoint.Definition))
- }
- basePath := idx.GetSpecAbsolutePath()
-
- if !filepath.IsAbs(basePath) && !strings.HasPrefix(basePath, "http") {
- basePath, _ = filepath.Abs(basePath)
- }
-
- if basePath == c.LoopPoint.FullDefinition {
- node, rewritten, refErr := rewrittenRefNode()
- return sp.circularReferenceResult(ctx, node, rewritten, refErr, cirError(c.LoopPoint.Definition))
- }
- a := utils.ReplaceWindowsDriveWithLinuxPath(strings.Replace(c.LoopPoint.FullDefinition, basePath, "", 1))
- b := sp.GetReference()
- if strings.HasPrefix(b, "./") {
- b = strings.Replace(b, "./", "/", 1) // strip any leading ./ from the reference
- }
- // if loading things in remotely and references are relative.
- if strings.HasPrefix(a, "http") {
- purl, _ := url.Parse(a)
- if purl != nil {
- specPath := filepath.Dir(purl.Path)
- host := fmt.Sprintf("%s://%s", purl.Scheme, purl.Host)
- a = strings.Replace(a, host, "", 1)
- a = strings.Replace(a, specPath, "", 1)
- }
- }
-
- aBase, aFragment := index.SplitRefFragment(a)
- bBase, bFragment := index.SplitRefFragment(b)
-
- if aFragment != "" && bFragment != "" && aFragment == bFragment {
- node, rewritten, refErr := rewrittenRefNode()
- return sp.circularReferenceResult(ctx, node, rewritten, refErr, cirError(c.LoopPoint.Definition))
- }
-
- if aFragment == "" && bFragment == "" {
- aNorm := strings.TrimPrefix(strings.TrimPrefix(aBase, "./"), "/")
- bNorm := strings.TrimPrefix(strings.TrimPrefix(bBase, "./"), "/")
- if aNorm != "" && bNorm != "" && aNorm == bNorm {
- node, rewritten, refErr := rewrittenRefNode()
- return sp.circularReferenceResult(ctx, node, rewritten, refErr, cirError(c.LoopPoint.Definition))
- }
- }
- }
+ if s != nil && s.GoLow() != nil && s.GoLow().Index != nil && sp.IsReference() {
+ if loopDefinition, found := sp.matchCircularReference(ctx, s.GoLow().Index); found {
+ node, rewritten, refErr := rewrittenRefNode()
+ return sp.circularReferenceResult(ctx, node, rewritten, refErr,
+ fmt.Errorf("schema render failure, circular reference: `%s`", loopDefinition))
}
}
@@ -1198,6 +1146,91 @@ func (sp *SchemaProxy) marshalYAMLInlineInternal(ctx *InlineRenderContext) (inte
return nil, errors.New("unable to render schema")
}
+// matchCircularReference reports whether this reference proxy closes a known circular reference, and the
+// loop point definition of the first one it matches. The circular references are the schema index's own,
+// then the rolodex root's (registered on the root, while an external $ref's resolved content is owned by
+// its own index), then the rolodex's ignored and safe circular references.
+func (sp *SchemaProxy) matchCircularReference(ctx *InlineRenderContext, idx *index.SpecIndex) (string, bool) {
+ sources := [4][]*index.CircularReferenceResult{idx.GetCircularReferences()}
+ if rolodex := idx.GetRolodex(); rolodex != nil {
+ if root := rolodex.GetRootIndex(); root != nil && root != idx {
+ sources[1] = root.GetCircularReferences()
+ }
+ sources[2] = rolodex.GetIgnoredCircularReferences()
+ sources[3] = rolodex.GetSafeCircularReferences()
+ }
+
+ if ctx.StrictCircularReferenceIdentity {
+ target := sp.referenceTargetIdentity(ctx)
+ if target == "" {
+ return "", false
+ }
+ for _, circular := range sources {
+ for _, c := range circular {
+ if c != nil && c.LoopPoint != nil &&
+ target == ctx.canonicalReferenceIdentity(c.LoopPoint.FullDefinition) {
+ return c.LoopPoint.Definition, true
+ }
+ }
+ }
+ return "", false
+ }
+
+ // everything derived from this proxy's own reference is the same for every candidate.
+ ref := sp.GetReference()
+ b := ref
+ if strings.HasPrefix(b, "./") {
+ b = strings.Replace(b, "./", "/", 1) // strip any leading ./ from the reference
+ }
+ bBase, bFragment := index.SplitRefFragment(b)
+ bNorm := strings.TrimPrefix(strings.TrimPrefix(bBase, "./"), "/")
+ basePath := ""
+ basePathResolved := false
+
+ for _, circular := range sources {
+ for _, c := range circular {
+ if c == nil || c.LoopPoint == nil {
+ continue
+ }
+ if ref == c.LoopPoint.Definition {
+ return c.LoopPoint.Definition, true
+ }
+ if !basePathResolved {
+ basePathResolved = true
+ basePath = ctx.absoluteSpecPath(idx.GetSpecAbsolutePath())
+ }
+ if basePath == c.LoopPoint.FullDefinition {
+ return c.LoopPoint.Definition, true
+ }
+ a := utils.ReplaceWindowsDriveWithLinuxPath(strings.Replace(c.LoopPoint.FullDefinition, basePath, "", 1))
+ // if loading things in remotely and references are relative.
+ if strings.HasPrefix(a, "http") {
+ purl, _ := url.Parse(a)
+ if purl != nil {
+ specPath := filepath.Dir(purl.Path)
+ host := fmt.Sprintf("%s://%s", purl.Scheme, purl.Host)
+ a = strings.Replace(a, host, "", 1)
+ a = strings.Replace(a, specPath, "", 1)
+ }
+ }
+
+ aBase, aFragment := index.SplitRefFragment(a)
+
+ if aFragment != "" && bFragment != "" && aFragment == bFragment {
+ return c.LoopPoint.Definition, true
+ }
+
+ if aFragment == "" && bFragment == "" {
+ aNorm := strings.TrimPrefix(strings.TrimPrefix(aBase, "./"), "/")
+ if aNorm != "" && bNorm != "" && aNorm == bNorm {
+ return c.LoopPoint.Definition, true
+ }
+ }
+ }
+ }
+ return "", false
+}
+
func (sp *SchemaProxy) marshalParsedRefWithSiblingsInline(ctx *InlineRenderContext, currentSibling *Schema) (interface{}, error) {
s, err := sp.buildSemanticAllOfSchemaView(currentSibling)
if err != nil {
diff --git a/datamodel/high/base/schema_proxy_test.go b/datamodel/high/base/schema_proxy_test.go
index 271cc77e..09f5414d 100644
--- a/datamodel/high/base/schema_proxy_test.go
+++ b/datamodel/high/base/schema_proxy_test.go
@@ -2572,3 +2572,50 @@ func TestSchemaProxy_MarshalYAMLInline_CircularReference_OtherWholeFile(t *testi
require.NoError(t, err)
assert.Equal(t, "type: object\n", string(out))
}
+
+// matchCircularReference walks every circular reference source in order and skips malformed entries,
+// in both identity modes, and reports no match when nothing closes a loop.
+func TestSchemaProxy_MatchCircularReference_NoMatch(t *testing.T) {
+ var idxNode yaml.Node
+ require.NoError(t, yaml.Unmarshal([]byte("components:\n schemas:\n Ten:\n type: object"), &idxNode))
+ idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig())
+ idx.SetAbsolutePath(filepath.Join(t.TempDir(), "spec.yaml"))
+ other := &index.Reference{Definition: "#/components/schemas/Other",
+ FullDefinition: idx.GetSpecAbsolutePath() + "#/components/schemas/Other"}
+ idx.SetCircularReferences([]*index.CircularReferenceResult{nil, {}, {LoopPoint: other}})
+
+ refNode := utils.CreateRefNode("#/components/schemas/Ten")
+ lowProxy := new(lowbase.SchemaProxy)
+ require.NoError(t, lowProxy.Build(context.Background(), nil, refNode, idx))
+ sp := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{Value: lowProxy, ValueNode: refNode})
+
+ loose := NewInlineRenderContext()
+ _, found := sp.matchCircularReference(loose, idx)
+ assert.False(t, found)
+
+ strict := NewInlineRenderContext()
+ strict.StrictCircularReferenceIdentity = true
+ for range 2 { // the second pass answers the loop point identity from the context's cache
+ _, found = sp.matchCircularReference(strict, idx)
+ assert.False(t, found)
+ }
+
+ // a reference with no resolvable target identity cannot close a loop in strict mode.
+ programmatic := CreateSchemaProxyRef("external.yaml#/Thing")
+ _, found = programmatic.matchCircularReference(strict, idx)
+ assert.False(t, found)
+}
+
+// absoluteSpecPath resolves a relative spec path the way filepath.Abs does, once per render context,
+// and leaves absolute and remote paths alone.
+func TestInlineRenderContext_AbsoluteSpecPath(t *testing.T) {
+ ctx := NewInlineRenderContext()
+ want, err := filepath.Abs("spec.yaml")
+ require.NoError(t, err)
+ assert.Equal(t, want, ctx.absoluteSpecPath("spec.yaml"))
+ assert.Equal(t, want, ctx.absoluteSpecPath("spec.yaml"))
+
+ abs := filepath.Join(t.TempDir(), "spec.yaml")
+ assert.Equal(t, abs, ctx.absoluteSpecPath(abs))
+ assert.Equal(t, "https://example.com/spec.yaml", ctx.absoluteSpecPath("https://example.com/spec.yaml"))
+}
diff --git a/datamodel/high/encode_cache.go b/datamodel/high/encode_cache.go
new file mode 100644
index 00000000..87e229c5
--- /dev/null
+++ b/datamodel/high/encode_cache.go
@@ -0,0 +1,175 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package high
+
+import (
+ "encoding/binary"
+ "sync"
+
+ "go.yaml.in/yaml/v4"
+)
+
+// The node builder encodes raw values (enum lists, required lists, examples and extension values) with
+// (*yaml.Node).Encode, which serializes the value to YAML text and parses it back. The same small values
+// repeat throughout a specification, and again every time a schema is rendered inline. Encode's result is
+// a pure function of the value's content (yaml ignores node positions when encoding), so a value with the
+// same content always encodes to the same tree: the tree is kept, and each render gets its own copy.
+
+const (
+ // encodeCacheMaxNodes bounds the values that are fingerprinted; larger ones are encoded directly.
+ encodeCacheMaxNodes = 64
+
+ // encodeCacheMaxKeyBytes bounds a fingerprint, so a large scalar is never held as a key.
+ encodeCacheMaxKeyBytes = 2048
+
+ // encodeCacheLimit bounds the entries kept; the cache is emptied when it fills.
+ encodeCacheLimit = 4096
+)
+
+var encodeCache = struct {
+ sync.RWMutex
+ entries map[string]*encodedValue
+}{entries: make(map[string]*encodedValue)}
+
+// ClearEncodeCache empties the cache of encoded values kept by the node builder. It holds no references
+// to model nodes, only its own copies of small encoded values, and is bounded in size.
+func ClearEncodeCache() {
+ encodeCache.Lock()
+ clear(encodeCache.entries)
+ encodeCache.Unlock()
+}
+
+// encodedValue is an encoded tree, with the node and Content slot counts of everything below its root.
+type encodedValue struct {
+ root yaml.Node
+ nodes int
+ contents int
+}
+
+// encodeValue stores in rawNode what rawNode.Encode(encodeSafeValue(value)) stores, reusing the result
+// of an earlier encoding of a value with the same content.
+func encodeValue(rawNode *yaml.Node, value any) error {
+ var scratch [256]byte
+ key, cacheable := appendEncodeFingerprint(scratch[:0], value)
+ if !cacheable || len(key) > encodeCacheMaxKeyBytes {
+ return rawNode.Encode(encodeSafeValue(value))
+ }
+ encodeCache.RLock()
+ cached := encodeCache.entries[string(key)]
+ encodeCache.RUnlock()
+ if cached != nil {
+ cached.copyInto(rawNode)
+ return nil
+ }
+ if err := rawNode.Encode(encodeSafeValue(value)); err != nil {
+ return err
+ }
+ entry := &encodedValue{}
+ entry.root = *rawNode
+ entry.copyChildren(&entry.root, rawNode, nil, nil)
+ encodeCache.Lock()
+ if len(encodeCache.entries) >= encodeCacheLimit {
+ clear(encodeCache.entries)
+ }
+ encodeCache.entries[string(key)] = entry
+ encodeCache.Unlock()
+ return nil
+}
+
+// copyInto stores a copy of the encoded tree in dst, allocating its nodes and Content slices in one go.
+func (e *encodedValue) copyInto(dst *yaml.Node) {
+ nodes := make([]yaml.Node, e.nodes)
+ contents := make([]*yaml.Node, e.contents)
+ *dst = e.root
+ e.copyChildren(dst, &e.root, &nodes, &contents)
+}
+
+// copyChildren deep copies src's children into dst, which already holds src's fields. With no slabs it
+// allocates each node and counts the tree into e; otherwise it carves the copies from the slabs.
+func (e *encodedValue) copyChildren(dst, src *yaml.Node, nodes *[]yaml.Node, contents *[]*yaml.Node) {
+ if src.Content == nil {
+ return
+ }
+ n := len(src.Content)
+ if contents == nil {
+ dst.Content = make([]*yaml.Node, n)
+ e.contents += n
+ } else {
+ dst.Content = (*contents)[:n:n]
+ *contents = (*contents)[n:]
+ }
+ for i, child := range src.Content {
+ var c *yaml.Node
+ if nodes == nil {
+ c = new(yaml.Node)
+ e.nodes++
+ } else {
+ c = &(*nodes)[0]
+ *nodes = (*nodes)[1:]
+ }
+ *c = *child
+ dst.Content[i] = c
+ e.copyChildren(c, child, nodes, contents)
+ }
+}
+
+// appendEncodeFingerprint appends an unambiguous encoding of everything Encode reads from value, and
+// reports whether the value can be cached: a node, a slice of nodes or a slice of strings, of bounded
+// size, with no anchors or aliases (whose encoding depends on nodes outside the value).
+func appendEncodeFingerprint(b []byte, value any) ([]byte, bool) {
+ budget := encodeCacheMaxNodes
+ switch v := value.(type) {
+ case *yaml.Node:
+ return appendNodeFingerprint(append(b, 'n'), v, &budget)
+ case []*yaml.Node:
+ b = binary.AppendUvarint(append(b, 'l'), uint64(len(v)))
+ for _, n := range v {
+ var ok bool
+ if b, ok = appendNodeFingerprint(b, n, &budget); !ok {
+ return b, false
+ }
+ }
+ return b, true
+ case []string:
+ if len(v) > encodeCacheMaxNodes {
+ return b, false
+ }
+ b = binary.AppendUvarint(append(b, 's'), uint64(len(v)))
+ for _, s := range v {
+ b = appendFingerprintString(b, s)
+ }
+ return b, true
+ }
+ return b, false
+}
+
+func appendNodeFingerprint(b []byte, n *yaml.Node, budget *int) ([]byte, bool) {
+ *budget--
+ if n == nil || *budget < 0 || n.Anchor != "" || n.Alias != nil || n.Stream != nil ||
+ (n.Kind != yaml.ScalarNode && n.Kind != yaml.SequenceNode && n.Kind != yaml.MappingNode) {
+ return b, false
+ }
+ b = binary.AppendUvarint(b, uint64(n.Kind))
+ b = binary.AppendUvarint(b, uint64(n.Style))
+ b = appendFingerprintString(b, n.Tag)
+ b = appendFingerprintString(b, n.Value)
+ b = appendFingerprintString(b, n.HeadComment)
+ b = appendFingerprintString(b, n.LineComment)
+ b = appendFingerprintString(b, n.FootComment)
+ if n.Content == nil {
+ return append(b, 0), true
+ }
+ b = binary.AppendUvarint(append(b, 1), uint64(len(n.Content)))
+ for _, c := range n.Content {
+ var ok bool
+ if b, ok = appendNodeFingerprint(b, c, budget); !ok {
+ return b, false
+ }
+ }
+ return b, true
+}
+
+func appendFingerprintString(b []byte, s string) []byte {
+ return append(binary.AppendUvarint(b, uint64(len(s))), s...)
+}
diff --git a/datamodel/high/encode_cache_test.go b/datamodel/high/encode_cache_test.go
new file mode 100644
index 00000000..62a4ae7e
--- /dev/null
+++ b/datamodel/high/encode_cache_test.go
@@ -0,0 +1,183 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package high
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/libopenapi/utils"
+ "github.com/pb33f/testify/assert"
+ "github.com/pb33f/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+// sameEncodedTree reports the first difference between two node trees, comparing every field.
+func sameEncodedTree(want, got *yaml.Node, path string) error {
+ switch {
+ case want.Kind != got.Kind || want.Style != got.Style || want.Tag != got.Tag || want.Value != got.Value:
+ return fmt.Errorf("%s: %v/%v/%q/%q != %v/%v/%q/%q", path, got.Kind, got.Style, got.Tag, got.Value,
+ want.Kind, want.Style, want.Tag, want.Value)
+ case want.Anchor != got.Anchor || want.Alias != got.Alias || want.Line != got.Line || want.Column != got.Column:
+ return fmt.Errorf("%s: anchor, alias or position differ", path)
+ case want.HeadComment != got.HeadComment || want.LineComment != got.LineComment ||
+ want.FootComment != got.FootComment:
+ return fmt.Errorf("%s: comments differ", path)
+ case (want.Content == nil) != (got.Content == nil) || len(want.Content) != len(got.Content):
+ return fmt.Errorf("%s: content differs", path)
+ }
+ for i := range want.Content {
+ if err := sameEncodedTree(want.Content[i], got.Content[i], fmt.Sprintf("%s/%d", path, i)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// requireEncodesLikeYAML asserts encodeValue matches Encode on a first (uncached) and second (cached) call.
+func requireEncodesLikeYAML(t *testing.T, value any) {
+ t.Helper()
+ var want yaml.Node
+ wantErr := want.Encode(encodeSafeValue(value))
+ for pass := 0; pass < 2; pass++ {
+ var got yaml.Node
+ err := encodeValue(&got, value)
+ require.Equal(t, wantErr, err)
+ require.NoError(t, sameEncodedTree(&want, &got, "value"), "pass %d", pass)
+ }
+}
+
+func walkNodes(n *yaml.Node, fn func(*yaml.Node)) {
+ fn(n)
+ for _, c := range n.Content {
+ walkNodes(c, fn)
+ }
+}
+
+// Every value encodes exactly as Encode encodes it, whether or not an earlier encoding is reused.
+func TestEncodeValue_MatchesEncode(t *testing.T) {
+ ClearEncodeCache()
+ fixtures := []string{"burgershop.openapi.yaml", "petstorev3.json", "all-the-components.yaml",
+ "vendor-extensions-components.yaml", "circular-tests.yaml", "nullable-examples.openapi.yaml"}
+ for _, fixture := range fixtures {
+ data, err := os.ReadFile(filepath.Join("..", "..", "test_specs", fixture))
+ require.NoError(t, err)
+ var doc yaml.Node
+ require.NoError(t, yaml.Unmarshal(data, &doc))
+ walkNodes(doc.Content[0], func(n *yaml.Node) {
+ if n.Kind == yaml.ScalarNode && n.Value == "" {
+ return // Encode panics on an empty scalar, cached or not
+ }
+ requireEncodesLikeYAML(t, n)
+ if n.Kind == yaml.SequenceNode {
+ requireEncodesLikeYAML(t, n.Content)
+ strs := make([]string, len(n.Content))
+ for i, c := range n.Content {
+ strs[i] = c.Value
+ }
+ requireEncodesLikeYAML(t, strs)
+ }
+ })
+ }
+ for _, value := range []any{
+ []string{}, []string{"", "true", "123", "a: b", "- x", "multi\nline", " padded ", "#hash", "yes"},
+ []*yaml.Node{}, []*yaml.Node{utils.CreateStringNode("one"), utils.CreateIntNode("2")},
+ &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{}},
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "commented", HeadComment: "# head",
+ LineComment: "# line", FootComment: "# foot"},
+ } {
+ requireEncodesLikeYAML(t, value)
+ }
+}
+
+// Values whose encoding cannot be reused are encoded directly, with the same result.
+func TestEncodeValue_Uncacheable(t *testing.T) {
+ ClearEncodeCache()
+ anchored := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "anchored", Anchor: "a"}
+ big := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
+ for i := 0; i <= encodeCacheMaxNodes; i++ {
+ big.Content = append(big.Content, utils.CreateIntNode(fmt.Sprint(i)))
+ }
+ manyStrings := make([]string, encodeCacheMaxNodes+1)
+ for i := range manyStrings {
+ manyStrings[i] = fmt.Sprint(i)
+ }
+ for _, value := range []any{
+ anchored,
+ &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{anchored}},
+ []*yaml.Node{utils.CreateStringNode("one"), anchored},
+ big,
+ manyStrings,
+ []string{strings.Repeat("long ", encodeCacheMaxKeyBytes)},
+ &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{utils.CreateStringNode("doc")}},
+ []int{1, 2},
+ map[string]string{"a": "b"},
+ } {
+ key, cacheable := appendEncodeFingerprint(nil, value)
+ assert.False(t, cacheable && len(key) <= encodeCacheMaxKeyBytes, "%T should not be cached", value)
+ requireEncodesLikeYAML(t, value)
+ }
+ encodeCache.RLock()
+ assert.Empty(t, encodeCache.entries)
+ encodeCache.RUnlock()
+
+ // an alias node and a nil slice element are never fingerprinted.
+ _, cacheable := appendEncodeFingerprint(nil, &yaml.Node{Kind: yaml.AliasNode, Alias: anchored})
+ assert.False(t, cacheable)
+ _, cacheable = appendEncodeFingerprint(nil, []*yaml.Node{nil})
+ assert.False(t, cacheable)
+}
+
+// An encoding error is returned as Encode returns it, and nothing is cached.
+func TestEncodeValue_Error(t *testing.T) {
+ ClearEncodeCache()
+ invalid := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!", Value: "no tag suffix"}
+ var want, got yaml.Node
+ wantErr := want.Encode(encodeSafeValue(invalid))
+ require.Error(t, wantErr)
+ require.Equal(t, wantErr, encodeValue(&got, invalid))
+ encodeCache.RLock()
+ assert.Empty(t, encodeCache.entries)
+ encodeCache.RUnlock()
+}
+
+// The cache is emptied when it fills, so it stays bounded.
+func TestEncodeValue_Bounded(t *testing.T) {
+ ClearEncodeCache()
+ for i := 0; i <= encodeCacheLimit; i++ {
+ var n yaml.Node
+ require.NoError(t, encodeValue(&n, []string{fmt.Sprint(i)}))
+ }
+ encodeCache.RLock()
+ assert.Len(t, encodeCache.entries, 1)
+ encodeCache.RUnlock()
+
+ ClearEncodeCache()
+ encodeCache.RLock()
+ assert.Empty(t, encodeCache.entries)
+ encodeCache.RUnlock()
+}
+
+// Rendering encodes copies of model nodes, so the model is never mutated by a render.
+func TestNodeBuilder_RenderLeavesNodesUnchanged(t *testing.T) {
+ type test struct {
+ Enum []*yaml.Node `yaml:"enum,omitempty"`
+ Example *yaml.Node `yaml:"example,omitempty"`
+ }
+ enum := []*yaml.Node{utils.CreateStringNode("a"), utils.CreateStringNode("b")}
+ example := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map",
+ Content: []*yaml.Node{utils.CreateStringNode("k"), utils.CreateStringNode("v")}}
+ for pass := 0; pass < 2; pass++ {
+ _, err := yaml.Marshal(NewNodeBuilder(&test{Enum: enum, Example: example}, nil).Render())
+ require.NoError(t, err)
+ for _, n := range append(enum, example.Content...) {
+ assert.Equal(t, "!!str", n.Tag)
+ assert.Equal(t, yaml.Style(0), n.Style)
+ }
+ assert.Equal(t, "!!map", example.Tag)
+ }
+}
diff --git a/datamodel/high/node_builder.go b/datamodel/high/node_builder.go
index 4d22e0a6..90ec9b6f 100644
--- a/datamodel/high/node_builder.go
+++ b/datamodel/high/node_builder.go
@@ -11,6 +11,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"unicode"
"github.com/pb33f/libopenapi/datamodel/high/nodes"
@@ -67,6 +68,103 @@ func originalFloatLexeme(value float64, lowValue any) (string, bool) {
return valueNode.Value, true
}
+// nodeBuilderField holds the reflection metadata NewNodeBuilder needs for one field of a high-level struct.
+// It depends only on the high and low struct types, so it is derived once per type pair and cached rather
+// than re-derived (field lookups by name, yaml tag parsing) for every object rendered.
+type nodeBuilderField struct {
+ index int // index of the field in the high-level struct
+ name string // field name, used as the NodeEntry key
+ extensions bool // the Extensions field, which renders its map entries rather than itself
+ tagName string // yaml tag name
+ renderZero bool
+ omitEmpty bool
+
+ // lowIndex is the index path of the same-named field in the low-level struct, nil when it has none.
+ lowIndex []int
+
+ // lowEmptier and lowValueNoder report whether the low field's value type (the element type for a
+ // pointer field) implements IsEmpty and GetValueNode. When it does, those methods are called through
+ // a pointer to the field instead of copying the field into an interface: the pointer's method set
+ // carries the value-receiver methods, so the result is identical. lowDynamic marks a value type that
+ // is itself an interface or pointer, whose methods depend on the value held at runtime.
+ lowEmptier bool
+ lowValueNoder bool
+ lowDynamic bool
+}
+
+type nodeBuilderTypes struct {
+ high reflect.Type
+ low reflect.Type
+}
+
+type lowEmptier interface{ IsEmpty() bool }
+
+type lowValueNoder interface{ GetValueNode() *yaml.Node }
+
+var (
+ nodeBuilderFieldCache sync.Map // nodeBuilderTypes -> []nodeBuilderField
+
+ lowEmptierType = reflect.TypeFor[lowEmptier]()
+ lowValueNoderType = reflect.TypeFor[lowValueNoder]()
+ hasKeyNodeType = reflect.TypeFor[low.HasKeyNode]()
+ stringType = reflect.TypeFor[string]()
+)
+
+// nodeBuilderFields returns the cached field metadata for a high-level struct type, paired with the
+// low-level struct type (nil when there is no low-level model).
+func nodeBuilderFields(highType, lowType reflect.Type) []nodeBuilderField {
+ key := nodeBuilderTypes{high: highType, low: lowType}
+ if cached, ok := nodeBuilderFieldCache.Load(key); ok {
+ return cached.([]nodeBuilderField)
+ }
+ fields := make([]nodeBuilderField, 0, highType.NumField())
+ for i := 0; i < highType.NumField(); i++ {
+ sf := highType.Field(i)
+ // only operate on exported fields.
+ if unicode.IsLower(rune(sf.Name[0])) {
+ continue
+ }
+ field := nodeBuilderField{index: i, name: sf.Name}
+ if lowType != nil {
+ if lsf, ok := lowType.FieldByName(sf.Name); ok {
+ field.lowIndex = lsf.Index
+ valueType := lsf.Type
+ if valueType.Kind() == reflect.Ptr {
+ valueType = valueType.Elem()
+ }
+ if valueType.Kind() == reflect.Interface || valueType.Kind() == reflect.Ptr {
+ field.lowDynamic = true
+ } else {
+ field.lowEmptier = valueType.Implements(lowEmptierType)
+ field.lowValueNoder = valueType.Implements(lowValueNoderType)
+ }
+ }
+ }
+ if sf.Name == "Extensions" {
+ field.extensions = true
+ fields = append(fields, field)
+ continue
+ }
+ tag := sf.Tag.Get("yaml")
+ if tag == "-" {
+ continue
+ }
+ tagParts := strings.Split(tag, ",")
+ field.tagName = tagParts[0]
+ for _, part := range tagParts {
+ if part == renderZero {
+ field.renderZero = true
+ }
+ if part == "omitempty" {
+ field.omitEmpty = true
+ }
+ }
+ fields = append(fields, field)
+ }
+ actual, _ := nodeBuilderFieldCache.LoadOrStore(key, fields)
+ return actual.([]nodeBuilderField)
+}
+
// NewNodeBuilder will create a new NodeBuilder instance, this is the only way to create a NodeBuilder.
// The function accepts a high level object and a low level object (need to be siblings/same type).
//
@@ -79,58 +177,122 @@ func NewNodeBuilder(high any, low any) *NodeBuilder {
nb.Low = low
}
+ // resolve the low-level struct once; its fields supply line numbers and original styles.
+ var lowStruct reflect.Value
+ var lowType reflect.Type
+ if low != nil {
+ if lv := reflect.ValueOf(low); !lv.IsZero() {
+ if lv.Kind() == reflect.Ptr {
+ lowStruct = lv.Elem()
+ } else {
+ lowStruct = lv
+ }
+ lowType = lowStruct.Type()
+ }
+ }
+
// extract fields from the high level object and add them into our node builder.
// this will allow us to extract the line numbers from the low level object as well.
- v := reflect.ValueOf(high).Elem()
- num := v.NumField()
- for i := 0; i < num; i++ {
- nb.add(v.Type().Field(i).Name, i)
+ highStruct := reflect.ValueOf(high).Elem()
+ fields := nodeBuilderFields(highStruct.Type(), lowType)
+ for i := range fields {
+ nb.add(&fields[i], highStruct, lowStruct)
}
return nb
}
-func (n *NodeBuilder) add(key string, i int) {
- // only operate on exported fields.
- if unicode.IsLower(rune(key[0])) {
- return
+// lowFieldHasContent reports whether a low-level field holds content, which keeps a zero high-level value
+// in the rendered output.
+func lowFieldHasContent(field *nodeBuilderField, lowFieldValue reflect.Value) bool {
+ if field.lowDynamic {
+ return dynamicLowFieldHasContent(lowFieldValue)
+ }
+ if !field.lowEmptier && !field.lowValueNoder {
+ return false
+ }
+ holder := lowFieldValue
+ if holder.Kind() == reflect.Ptr {
+ if holder.IsNil() {
+ return false
+ }
+ } else if holder.CanAddr() {
+ holder = holder.Addr()
+ }
+ h := holder.Interface()
+ if field.lowEmptier && !h.(lowEmptier).IsEmpty() {
+ return true
+ }
+ return field.lowValueNoder && h.(lowValueNoder).GetValueNode() != nil
+}
+
+// dynamicLowFieldHasContent is lowFieldHasContent for a field whose methods depend on the value it holds.
+func dynamicLowFieldHasContent(lowFieldValue reflect.Value) bool {
+ var lowInterface any
+ if lowFieldValue.Kind() == reflect.Ptr {
+ if lowFieldValue.IsNil() {
+ return false
+ }
+ lowInterface = lowFieldValue.Elem().Interface()
+ } else {
+ lowInterface = lowFieldValue.Interface()
+ }
+ if emptier, ok := lowInterface.(lowEmptier); ok && !emptier.IsEmpty() {
+ return true
}
+ if nodeGetter, ok := lowInterface.(lowValueNoder); ok {
+ return nodeGetter.GetValueNode() != nil
+ }
+ return false
+}
+// lowestKeyLine returns the lowest key line of the items in a low-level slice, where items without a key
+// node count as line zero.
+func lowestKeyLine(value reflect.Value) int {
+ elemType := value.Type().Elem()
+ dynamic := elemType.Kind() == reflect.Interface
+ if !dynamic && !elemType.Implements(hasKeyNodeType) {
+ // no item can have a key node, so every item counts as line zero.
+ return 0
+ }
+ lowest := 0
+ for g := 0; g < value.Len(); g++ {
+ item := value.Index(g)
+ if !dynamic && item.Kind() != reflect.Ptr {
+ item = item.Addr() // call through a pointer rather than copying the item
+ }
+ line := 0
+ if we, ok := item.Interface().(low.HasKeyNode); ok {
+ line = we.GetKeyNode().Line
+ }
+ if g == 0 || line < lowest {
+ lowest = line
+ }
+ }
+ return lowest
+}
+
+func (n *NodeBuilder) add(field *nodeBuilderField, highStruct, lowStruct reflect.Value) {
var (
lowFieldValue reflect.Value
lowFieldValid bool
)
- if n.Low != nil && !reflect.ValueOf(n.Low).IsZero() {
- low := reflect.ValueOf(n.Low)
- if low.Kind() == reflect.Ptr && !low.IsNil() {
- elem := low.Elem()
- if elem.IsValid() {
- field := elem.FieldByName(key)
- if field.IsValid() {
- lowFieldValue = field
- lowFieldValid = true
- }
- }
- } else if low.IsValid() {
- field := low.FieldByName(key)
- if field.IsValid() {
- lowFieldValue = field
- lowFieldValid = true
- }
- }
+ if lowStruct.IsValid() && field.lowIndex != nil {
+ lowFieldValue = lowStruct.FieldByIndex(field.lowIndex)
+ lowFieldValid = true
}
// if the key is 'Extensions' then we need to extract the keys from the map
// and add them to the node builder.
- if key == "Extensions" {
- ev := reflect.ValueOf(n.High).Elem().FieldByName(key).Interface()
+ if field.extensions {
+ ev := highStruct.Field(field.index).Interface()
var extensions *orderedmap.Map[string, *yaml.Node]
if ev != nil {
extensions = ev.(*orderedmap.Map[string, *yaml.Node])
}
var lowExtensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]
- if n.Low != nil && !reflect.ValueOf(n.Low).IsZero() {
+ if lowStruct.IsValid() {
if j, ok := n.Low.(low.HasExtensionsUntyped); ok {
lowExtensions = j.GetExtensions()
}
@@ -160,28 +322,11 @@ func (n *NodeBuilder) add(key string, i int) {
return
}
- // find the field with the tag supplied.
- field, _ := reflect.TypeOf(n.High).Elem().FieldByName(key)
- tag := string(field.Tag.Get("yaml"))
- tagName := strings.Split(tag, ",")[0]
-
- if tag == "-" {
- return
- }
-
- var renderZeroFlag, omitEmptyFlag bool
- tagParts := strings.Split(tag, ",")
- for _, part := range tagParts {
- if part == renderZero {
- renderZeroFlag = true
- }
- if part == "omitempty" {
- omitEmptyFlag = true
- }
- }
+ tagName := field.tagName
+ renderZeroFlag, omitEmptyFlag := field.renderZero, field.omitEmpty
// extract the value of the field
- fieldValue := reflect.ValueOf(n.High).Elem().FieldByName(key)
+ fieldValue := highStruct.Field(field.index)
f := fieldValue.Interface()
value := reflect.ValueOf(f)
var isZero bool
@@ -199,24 +344,8 @@ func (n *NodeBuilder) add(key string, i int) {
}
}
- if isZero && lowFieldValid {
- var lowInterface any
- if lowFieldValue.Kind() == reflect.Ptr {
- if !lowFieldValue.IsNil() {
- lowInterface = lowFieldValue.Elem().Interface()
- }
- } else {
- lowInterface = lowFieldValue.Interface()
- }
- if lowInterface != nil {
- if emptier, ok := lowInterface.(interface{ IsEmpty() bool }); ok && !emptier.IsEmpty() {
- isZero = false
- } else if nodeGetter, ok := lowInterface.(interface{ GetValueNode() *yaml.Node }); ok {
- if node := nodeGetter.GetValueNode(); node != nil {
- isZero = false
- }
- }
- }
+ if isZero && lowFieldValid && lowFieldHasContent(field, lowFieldValue) {
+ isZero = false
}
if !renderZeroFlag && isZero || omitEmptyFlag && isZero {
@@ -224,7 +353,7 @@ func (n *NodeBuilder) add(key string, i int) {
}
// create a new node entry
- nodeEntry := &nodes.NodeEntry{Tag: tagName, Key: key}
+ nodeEntry := &nodes.NodeEntry{Tag: tagName, Key: field.name}
nodeEntry.RenderZero = renderZeroFlag
switch value.Kind() {
case reflect.Float64, reflect.Float32:
@@ -235,7 +364,11 @@ func (n *NodeBuilder) add(key string, i int) {
nodeEntry.Value = value.Int()
nodeEntry.StringValue = value.String()
case reflect.String:
- nodeEntry.Value = value.String()
+ if value.Type() == stringType {
+ nodeEntry.Value = f // already boxed as a plain string
+ } else {
+ nodeEntry.Value = value.String()
+ }
case reflect.Bool:
nodeEntry.Value = value.Bool()
case reflect.Slice:
@@ -261,48 +394,32 @@ func (n *NodeBuilder) add(key string, i int) {
// if there is no low-level object, then we cannot extract line numbers,
// so skip and default to 0, which means a new entry to the spec.
// this will place new content and the top of the rendered object.
- if n.Low != nil && !reflect.ValueOf(n.Low).IsZero() {
- if lowFieldValid {
- fLow := lowFieldValue.Interface()
- value = reflect.ValueOf(fLow)
-
- nodeEntry.LowValue = fLow
- switch value.Kind() {
-
- case reflect.Slice:
- l := value.Len()
- lines := make([]int, l)
- for g := 0; g < l; g++ {
- qw := value.Index(g).Interface()
- if we, wok := qw.(low.HasKeyNode); wok {
- lines[g] = we.GetKeyNode().Line
+ if lowFieldValid {
+ fLow := lowFieldValue.Interface()
+ value = reflect.ValueOf(fLow)
+
+ nodeEntry.LowValue = fLow
+ switch value.Kind() {
+
+ case reflect.Slice:
+ nodeEntry.Line = lowestKeyLine(value)
+ case reflect.Struct:
+ nodeEntry.Line = 9999 + field.index
+ if nb, ok := fLow.(low.HasValueNodeUntyped); ok {
+ if nb.IsReference() {
+ if jk, kj := fLow.(low.HasKeyNode); kj {
+ nodeEntry.Line = jk.GetKeyNode().Line
+ break
}
}
- sort.Slice(lines, func(i, j int) bool {
- return lines[i] < lines[j]
- })
- if len(lines) > 0 {
- nodeEntry.Line = lines[0]
+ if nb.GetValueNode() != nil {
+ nodeEntry.Line = nb.GetValueNode().Line
}
- case reflect.Struct:
- y := value.Interface()
- nodeEntry.Line = 9999 + i
- if nb, ok := y.(low.HasValueNodeUntyped); ok {
- if nb.IsReference() {
- if jk, kj := y.(low.HasKeyNode); kj {
- nodeEntry.Line = jk.GetKeyNode().Line
- break
- }
- }
- if nb.GetValueNode() != nil {
- nodeEntry.Line = nb.GetValueNode().Line
- }
- }
- default:
- // everything else, weight it to the bottom of the rendered object.
- // this is things that we have no way of knowing where they should be placed.
- nodeEntry.Line = 9999 + i
}
+ default:
+ // everything else, weight it to the bottom of the rendered object.
+ // this is things that we have no way of knowing where they should be placed.
+ nodeEntry.Line = 9999 + field.index
}
}
if nodeEntry.Value != nil {
@@ -350,14 +467,21 @@ func (n *NodeBuilder) Render() *yaml.Node {
}
// encodeSafeValue returns a value safe to pass to (*yaml.Node).Encode. When the
-// value is a *yaml.Node it returns a deep copy: Encode desolves the represented
-// graph in place (Desolve rewrites Tag/Style), and the representer aliases input
-// nodes, so encoding a model-owned node would mutate it. With concurrent renders
-// (e.g. linters running rules in parallel) that mutation races with readers of
+// value is a *yaml.Node, or a slice of them, it returns a deep copy: Encode desolves
+// the represented graph in place (Desolve rewrites Tag/Style), and the representer
+// aliases input nodes, so encoding a model-owned node would mutate it. With concurrent
+// renders (e.g. linters running rules in parallel) that mutation races with readers of
// the same node. Encoding a copy keeps shared nodes immutable.
func encodeSafeValue(value any) any {
- if vn, ok := value.(*yaml.Node); ok {
- return utils.CloneYAMLNode(vn)
+ switch v := value.(type) {
+ case *yaml.Node:
+ return utils.CloneYAMLNode(v)
+ case []*yaml.Node:
+ cloned := make([]*yaml.Node, len(v))
+ for i, n := range v {
+ cloned[i] = utils.CloneYAMLNode(n)
+ }
+ return cloned
}
return value
}
@@ -504,7 +628,7 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya
break
}
- if err := rawNode.Encode(encodeSafeValue(value)); err != nil {
+ if err := encodeValue(&rawNode, value); err != nil {
// an item that failed to render has already reported why, and the encoder only echoes it.
if errors.Join(nodeErrors...) == nil {
nodeErrors = append(nodeErrors, err)
@@ -651,7 +775,7 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya
}
}
- if err := rawNode.Encode(encodeSafeValue(value)); err != nil {
+ if err := encodeValue(&rawNode, value); err != nil {
nodeErrors = append(nodeErrors, err)
} else {
valueNode = &rawNode
diff --git a/datamodel/high/node_builder_test.go b/datamodel/high/node_builder_test.go
index f55c9104..a1f4a0f8 100644
--- a/datamodel/high/node_builder_test.go
+++ b/datamodel/high/node_builder_test.go
@@ -1837,3 +1837,62 @@ func TestNewNodeBuilder_FalseBoolPointerOmitted(t *testing.T) {
assert.Empty(t, nb.Errors)
assert.Empty(t, parent.Content)
}
+
+type emptyWithValueNode struct{}
+
+func (emptyWithValueNode) IsEmpty() bool { return true }
+func (emptyWithValueNode) GetValueNode() *yaml.Node { return utils.CreateStringNode("held") }
+
+type emptyWithoutValueNode struct{}
+
+func (emptyWithoutValueNode) IsEmpty() bool { return true }
+func (emptyWithoutValueNode) GetValueNode() *yaml.Node { return nil }
+
+type dynamicLowHigh struct {
+ Held string `yaml:"held,omitempty"`
+}
+
+type dynamicLowAny struct {
+ Held any
+}
+
+type dynamicLowPointer struct {
+ Held *any
+}
+
+// An interface-typed low field (or a pointer to one) can only be checked for content through the value
+// it holds at runtime, so a zero high value renders exactly when that value reports content.
+func TestNewNodeBuilder_DynamicLowFieldContent(t *testing.T) {
+ render := func(low any) string {
+ data, err := yaml.Marshal(NewNodeBuilder(&dynamicLowHigh{}, low).Render())
+ require.NoError(t, err)
+ return strings.TrimSpace(string(data))
+ }
+ held := func(v any) *any { return &v }
+
+ assert.Equal(t, "{}", render(&dynamicLowAny{}))
+ assert.Equal(t, `held: ""`, render(&dynamicLowAny{Held: nonEmptyExample{}}))
+ assert.Equal(t, `held: ""`, render(&dynamicLowAny{Held: emptyWithValueNode{}}))
+ assert.Equal(t, "{}", render(&dynamicLowAny{Held: emptyWithoutValueNode{}}))
+ assert.Equal(t, "{}", render(&dynamicLowAny{Held: struct{}{}}))
+
+ assert.Equal(t, "{}", render(&dynamicLowPointer{}))
+ assert.Equal(t, `held: ""`, render(&dynamicLowPointer{Held: held(nonEmptyExample{})}))
+ assert.Equal(t, "{}", render(&dynamicLowPointer{Held: held(struct{}{})}))
+}
+
+type namedString string
+
+// A field with a named string type renders its value as a plain string.
+func TestNewNodeBuilder_NamedStringType(t *testing.T) {
+ type test struct {
+ Name namedString `yaml:"name"`
+ }
+ nb := NewNodeBuilder(&test{Name: "chicken"}, nil)
+ require.Len(t, nb.Nodes, 1)
+ assert.Equal(t, "chicken", nb.Nodes[0].Value)
+
+ data, err := yaml.Marshal(nb.Render())
+ require.NoError(t, err)
+ assert.Equal(t, "name: chicken", strings.TrimSpace(string(data)))
+}
diff --git a/datamodel/low/base/contact.go b/datamodel/low/base/contact.go
index 8b33c1f5..ce952cc3 100644
--- a/datamodel/low/base/contact.go
+++ b/datamodel/low/base/contact.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -27,7 +26,7 @@ type Contact struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -38,7 +37,7 @@ func (c *Contact) Build(ctx context.Context, keyNode, root *yaml.Node, idx *inde
c.RootNode = root
c.reference = low.Reference{}
c.Reference = &c.reference
- c.nodeStore = sync.Map{}
+ c.nodeStore = low.NodeLines{}
c.Nodes = &c.nodeStore
if root == nil {
c.Extensions = nil
diff --git a/datamodel/low/base/example.go b/datamodel/low/base/example.go
index f6b81075..ca2b608b 100644
--- a/datamodel/low/base/example.go
+++ b/datamodel/low/base/example.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -30,7 +29,7 @@ type Example struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -97,7 +96,7 @@ func (ex *Example) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
root = utils.NodeAlias(root)
ex.RootNode = root
utils.CheckForMergeNodes(root)
- ex.nodeStore = sync.Map{}
+ ex.nodeStore = low.NodeLines{}
ex.Nodes = &ex.nodeStore
if len(root.Content) > 0 {
ex.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/base/external_doc.go b/datamodel/low/base/external_doc.go
index b9054a8c..13c477e4 100644
--- a/datamodel/low/base/external_doc.go
+++ b/datamodel/low/base/external_doc.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -29,7 +28,7 @@ type ExternalDoc struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -55,7 +54,7 @@ func (ex *ExternalDoc) Build(ctx context.Context, keyNode, root *yaml.Node, idx
ex.KeyNode = keyNode
ex.reference = low.Reference{}
ex.Reference = &ex.reference
- ex.nodeStore = sync.Map{}
+ ex.nodeStore = low.NodeLines{}
ex.Nodes = &ex.nodeStore
ex.context = ctx
ex.index = idx
diff --git a/datamodel/low/base/info.go b/datamodel/low/base/info.go
index 8cb2dc58..90df059a 100644
--- a/datamodel/low/base/info.go
+++ b/datamodel/low/base/info.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/orderedmap"
"github.com/pb33f/libopenapi/utils"
@@ -36,7 +35,7 @@ type Info struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -67,7 +66,7 @@ func (i *Info) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.S
i.KeyNode = keyNode
i.reference = low.Reference{}
i.Reference = &i.reference
- i.nodeStore = sync.Map{}
+ i.nodeStore = low.NodeLines{}
i.Nodes = &i.nodeStore
i.index = idx
i.context = ctx
diff --git a/datamodel/low/base/license.go b/datamodel/low/base/license.go
index c6ec47a1..147b8653 100644
--- a/datamodel/low/base/license.go
+++ b/datamodel/low/base/license.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -28,7 +27,7 @@ type License struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -39,7 +38,7 @@ func (l *License) Build(ctx context.Context, keyNode, root *yaml.Node, idx *inde
l.KeyNode = keyNode
l.reference = low.Reference{}
l.Reference = &l.reference
- l.nodeStore = sync.Map{}
+ l.nodeStore = low.NodeLines{}
l.Nodes = &l.nodeStore
l.context = ctx
l.index = idx
diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go
index 36074ac3..463e25ee 100644
--- a/datamodel/low/base/schema.go
+++ b/datamodel/low/base/schema.go
@@ -2,7 +2,6 @@ package base
import (
"context"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -161,7 +160,7 @@ type Schema struct {
refIndex *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
diff --git a/datamodel/low/base/schema_build.go b/datamodel/low/base/schema_build.go
index 86526a60..bda3b422 100644
--- a/datamodel/low/base/schema_build.go
+++ b/datamodel/low/base/schema_build.go
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"strconv"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -49,7 +48,7 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde
s.reference = low.Reference{}
s.Reference = &s.reference
- s.nodeStore = sync.Map{}
+ s.nodeStore = low.NodeLines{}
s.Nodes = &s.nodeStore
if root != nil && len(root.Content) > 0 {
s.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/base/schema_build_coverage_test.go b/datamodel/low/base/schema_build_coverage_test.go
index f6f06b2b..899f8204 100644
--- a/datamodel/low/base/schema_build_coverage_test.go
+++ b/datamodel/low/base/schema_build_coverage_test.go
@@ -217,7 +217,7 @@ func TestRecursiveSchemaNodeHelpers(t *testing.T) {
require.NoError(t, yaml.Unmarshal([]byte("example:\n nested:\n value: ok\n"), &root))
node := root.Content[0]
- var dst sync.Map
+ var dst low.NodeLines
blockedLine := node.Content[0].Line
dst.Store(blockedLine, []*yaml.Node{{Value: "existing"}})
@@ -227,8 +227,8 @@ func TestRecursiveSchemaNodeHelpers(t *testing.T) {
assert.True(t, blocked)
var foundNested bool
- dst.Range(func(key, value any) bool {
- if key.(int) == node.Content[1].Content[0].Line {
+ dst.Range(func(key int, value any) bool {
+ if key == node.Content[1].Content[0].Line {
foundNested = true
}
assert.NotNil(t, value)
diff --git a/datamodel/low/base/schema_proxy.go b/datamodel/low/base/schema_proxy.go
index be246c3a..375ad8c6 100644
--- a/datamodel/low/base/schema_proxy.go
+++ b/datamodel/low/base/schema_proxy.go
@@ -67,7 +67,7 @@ type SchemaProxy struct {
hashMu sync.Mutex // protects cachedHash + hashGen
cachedHash *uint64 // protected by hashMu
hashGen uint64 // generation counter for invalidation
- nodeStore sync.Map
+ nodeStore low.NodeLines
nodeMap low.NodeMap
TransformedRef *yaml.Node // Original node that contained the ref before transformation
transformedRef *transformedSiblingRef
@@ -103,7 +103,7 @@ 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
- sp.nodeStore = sync.Map{}
+ sp.nodeStore = low.NodeLines{}
sp.nodeMap = low.NodeMap{Nodes: &sp.nodeStore}
sp.NodeMap = &sp.nodeMap
return nil
@@ -133,7 +133,7 @@ func (sp *SchemaProxy) prepareForResolvedBuild(ctx context.Context, key, value,
if refLocation != "" {
sp.SetReference(refLocation, refNode)
}
- sp.nodeStore = sync.Map{}
+ sp.nodeStore = low.NodeLines{}
sp.nodeMap = low.NodeMap{Nodes: &sp.nodeStore}
sp.NodeMap = &sp.nodeMap
}
@@ -230,8 +230,10 @@ func (sp *SchemaProxy) Schema() *Schema {
// handle property merging for references with sibling properties
buildNode := sp.vn
if cfg != nil {
- if docConfig := sp.getDocumentConfig(); docConfig != nil && docConfig.MergeReferencedProperties {
- if mergedNode := sp.attemptPropertyMerging(buildNode, docConfig); mergedNode != nil {
+ // read the flag straight from the rolodex config: the document configuration is only built
+ // when merging is actually enabled.
+ if rolodexConfig := sp.rolodexConfig(); rolodexConfig != nil && rolodexConfig.MergeReferencedProperties {
+ if mergedNode := sp.attemptPropertyMerging(buildNode, rolodexConfig.ToDocumentConfiguration()); mergedNode != nil {
buildNode = mergedNode
}
}
@@ -253,8 +255,8 @@ func (sp *SchemaProxy) Schema() *Schema {
// Copy accumulated nodes to the built schema
if sp.NodeMap != nil {
- sp.NodeMap.Nodes.Range(func(key, value any) bool {
- schema.AddNode(key.(int), value.(*yaml.Node))
+ sp.NodeMap.Nodes.Range(func(line int, value any) bool {
+ schema.AddNode(line, value.(*yaml.Node))
return true
})
}
@@ -418,16 +420,12 @@ type HasIndex interface {
GetIndex() *index.SpecIndex
}
-// getDocumentConfig retrieves the document configuration from the index
-func (sp *SchemaProxy) getDocumentConfig() *datamodel.DocumentConfiguration {
+// rolodexConfig returns the index configuration of the rolodex this proxy's index belongs to, or nil.
+func (sp *SchemaProxy) rolodexConfig() *index.SpecIndexConfig {
if sp.idx == nil || sp.idx.GetRolodex() == nil {
return nil
}
- rolodex := sp.idx.GetRolodex()
- if config := rolodex.GetConfig(); config != nil {
- return config.ToDocumentConfiguration()
- }
- return nil
+ return sp.idx.GetRolodex().GetConfig()
}
// attemptPropertyMerging attempts to merge properties for references with siblings
diff --git a/datamodel/low/base/schema_proxy_test.go b/datamodel/low/base/schema_proxy_test.go
index 0156f04a..9091fcec 100644
--- a/datamodel/low/base/schema_proxy_test.go
+++ b/datamodel/low/base/schema_proxy_test.go
@@ -648,7 +648,7 @@ components:
assert.NotNil(t, schema)
sp.idx.SetRolodex(&index.Rolodex{}) // set a rolodex to avoid nil deref
- assert.Nil(t, sp.getDocumentConfig())
+ assert.Nil(t, sp.rolodexConfig())
}
func TestSchemaProxy_attemptPropertyMerging_MergeError(t *testing.T) {
diff --git a/datamodel/low/base/security_requirement.go b/datamodel/low/base/security_requirement.go
index ba2fa5cb..2a7adbb8 100644
--- a/datamodel/low/base/security_requirement.go
+++ b/datamodel/low/base/security_requirement.go
@@ -7,7 +7,6 @@ import (
"context"
"hash/maphash"
"sort"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -31,7 +30,7 @@ type SecurityRequirement struct {
ContainsEmptyRequirement bool // if a requirement is empty (this means it's optional)
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -52,7 +51,7 @@ func (s *SecurityRequirement) Build(ctx context.Context, keyNode, root *yaml.Nod
s.KeyNode = keyNode
s.reference = low.Reference{}
s.Reference = &s.reference
- s.nodeStore = sync.Map{}
+ s.nodeStore = low.NodeLines{}
s.Nodes = &s.nodeStore
s.context = ctx
s.index = idx
diff --git a/datamodel/low/base/xml.go b/datamodel/low/base/xml.go
index 29d383af..a7324947 100644
--- a/datamodel/low/base/xml.go
+++ b/datamodel/low/base/xml.go
@@ -6,7 +6,6 @@ package base
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -35,7 +34,7 @@ type XML struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -45,7 +44,7 @@ type XML struct {
func (x *XML) Build(root *yaml.Node, idx *index.SpecIndex) error {
x.reference = low.Reference{}
x.Reference = &x.reference
- x.nodeStore = sync.Map{}
+ x.nodeStore = low.NodeLines{}
x.Nodes = &x.nodeStore
x.index = idx
if root == nil {
diff --git a/datamodel/low/model_builder.go b/datamodel/low/model_builder.go
index 7b721b8f..46b4ea32 100644
--- a/datamodel/low/model_builder.go
+++ b/datamodel/low/model_builder.go
@@ -113,6 +113,32 @@ func BuildModel(node *yaml.Node, model interface{}) error {
return nil
}
+// setField*Type are the field types SetField switches on, computed once rather than on every call.
+var (
+ setFieldMapStringNodeReferenceYAMLNodeType = reflect.TypeFor[*orderedmap.Map[string, NodeReference[*yaml.Node]]]()
+ setFieldMapStringNodeReferenceStringType = reflect.TypeFor[*orderedmap.Map[string, NodeReference[string]]]()
+ setFieldNodeReferenceYAMLNodeType = reflect.TypeFor[NodeReference[*yaml.Node]]()
+ setFieldSliceOfNodeReferenceYAMLNodeType = reflect.TypeFor[[]NodeReference[*yaml.Node]]()
+ setFieldNodeReferenceStringType = reflect.TypeFor[NodeReference[string]]()
+ setFieldValueReferenceStringType = reflect.TypeFor[ValueReference[string]]()
+ setFieldNodeReferenceBoolType = reflect.TypeFor[NodeReference[bool]]()
+ setFieldNodeReferenceIntType = reflect.TypeFor[NodeReference[int]]()
+ setFieldNodeReferenceInt64Type = reflect.TypeFor[NodeReference[int64]]()
+ setFieldNodeReferenceFloat32Type = reflect.TypeFor[NodeReference[float32]]()
+ setFieldNodeReferenceFloat64Type = reflect.TypeFor[NodeReference[float64]]()
+ setFieldSliceOfNodeReferenceStringType = reflect.TypeFor[[]NodeReference[string]]()
+ setFieldSliceOfNodeReferenceFloat32Type = reflect.TypeFor[[]NodeReference[float32]]()
+ setFieldSliceOfNodeReferenceFloat64Type = reflect.TypeFor[[]NodeReference[float64]]()
+ setFieldSliceOfNodeReferenceIntType = reflect.TypeFor[[]NodeReference[int]]()
+ setFieldSliceOfNodeReferenceInt64Type = reflect.TypeFor[[]NodeReference[int64]]()
+ setFieldSliceOfNodeReferenceBoolType = reflect.TypeFor[[]NodeReference[bool]]()
+ setFieldMapKeyReferenceStringValueReferenceStringType = reflect.TypeFor[*orderedmap.Map[KeyReference[string], ValueReference[string]]]()
+ setFieldKeyReferenceMapKeyReferenceStringValueReferenceStringType = reflect.TypeFor[KeyReference[*orderedmap.Map[KeyReference[string], ValueReference[string]]]]()
+ setFieldNodeReferenceMapKeyReferenceStringValueReferenceStringType = reflect.TypeFor[NodeReference[*orderedmap.Map[KeyReference[string], ValueReference[string]]]]()
+ setFieldNodeReferenceSliceValueReferenceStringType = reflect.TypeFor[NodeReference[[]ValueReference[string]]]()
+ setFieldNodeReferenceSliceValueReferenceYAMLNodeType = reflect.TypeFor[NodeReference[[]ValueReference[*yaml.Node]]]()
+)
+
// SetField accepts a field reflection value, a yaml.Node valueNode and a yaml.Node keyNode. Using reflection, the
// function will attempt to set the value of the field based on the key and value nodes. This method is only useful
// for low-level models, it has no value to high-level ones.
@@ -123,7 +149,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
switch field.Type() {
- case reflect.TypeOf(orderedmap.New[string, NodeReference[*yaml.Node]]()):
+ case setFieldMapStringNodeReferenceYAMLNodeType:
if utils.IsNodeMap(valueNode) {
if field.CanSet() {
@@ -144,7 +170,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(orderedmap.New[string, NodeReference[string]]()):
+ case setFieldMapStringNodeReferenceStringType:
if utils.IsNodeMap(valueNode) {
if field.CanSet() {
@@ -165,14 +191,14 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[*yaml.Node]{}):
+ case setFieldNodeReferenceYAMLNodeType:
if field.CanSet() {
or := NodeReference[*yaml.Node]{Value: valueNode, ValueNode: valueNode, KeyNode: keyNode}
field.Set(reflect.ValueOf(or))
}
- case reflect.TypeOf([]NodeReference[*yaml.Node]{}):
+ case setFieldSliceOfNodeReferenceYAMLNodeType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -188,7 +214,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[string]{}):
+ case setFieldNodeReferenceStringType:
if field.CanSet() {
nr := NodeReference[string]{
@@ -199,7 +225,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
field.Set(reflect.ValueOf(nr))
}
- case reflect.TypeOf(ValueReference[string]{}):
+ case setFieldValueReferenceStringType:
if field.CanSet() {
nr := ValueReference[string]{
@@ -209,7 +235,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
field.Set(reflect.ValueOf(nr))
}
- case reflect.TypeOf(NodeReference[bool]{}):
+ case setFieldNodeReferenceBoolType:
if utils.IsNodeBoolValue(valueNode) {
if field.CanSet() {
@@ -223,7 +249,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[int]{}):
+ case setFieldNodeReferenceIntType:
if utils.IsNodeIntValue(valueNode) {
if field.CanSet() {
@@ -237,7 +263,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[int64]{}):
+ case setFieldNodeReferenceInt64Type:
if utils.IsNodeIntValue(valueNode) || utils.IsNodeFloatValue(valueNode) {
if field.CanSet() {
@@ -251,7 +277,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[float32]{}):
+ case setFieldNodeReferenceFloat32Type:
if utils.IsNodeNumberValue(valueNode) {
if field.CanSet() {
@@ -265,7 +291,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[float64]{}):
+ case setFieldNodeReferenceFloat64Type:
if utils.IsNodeNumberValue(valueNode) {
if field.CanSet() {
@@ -279,7 +305,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[string]{}):
+ case setFieldSliceOfNodeReferenceStringType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -295,7 +321,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[float32]{}):
+ case setFieldSliceOfNodeReferenceFloat32Type:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -312,7 +338,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[float64]{}):
+ case setFieldSliceOfNodeReferenceFloat64Type:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -325,7 +351,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[int]{}):
+ case setFieldSliceOfNodeReferenceIntType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -342,7 +368,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[int64]{}):
+ case setFieldSliceOfNodeReferenceInt64Type:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -359,7 +385,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf([]NodeReference[bool]{}):
+ case setFieldSliceOfNodeReferenceBoolType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -377,7 +403,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
// helper for unpacking string maps.
- case reflect.TypeOf(orderedmap.New[KeyReference[string], ValueReference[string]]()):
+ case setFieldMapKeyReferenceStringValueReferenceStringType:
if utils.IsNodeMap(valueNode) {
if field.CanSet() {
@@ -397,7 +423,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(KeyReference[*orderedmap.Map[KeyReference[string], ValueReference[string]]]{}):
+ case setFieldKeyReferenceMapKeyReferenceStringValueReferenceStringType:
if utils.IsNodeMap(valueNode) {
if field.CanSet() {
@@ -420,7 +446,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
field.Set(reflect.ValueOf(ref))
}
}
- case reflect.TypeOf(NodeReference[*orderedmap.Map[KeyReference[string], ValueReference[string]]]{}):
+ case setFieldNodeReferenceMapKeyReferenceStringValueReferenceStringType:
if utils.IsNodeMap(valueNode) {
if field.CanSet() {
items := orderedmap.New[KeyReference[string], ValueReference[string]]()
@@ -443,7 +469,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
field.Set(reflect.ValueOf(ref))
}
}
- case reflect.TypeOf(NodeReference[[]ValueReference[string]]{}):
+ case setFieldNodeReferenceSliceValueReferenceStringType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
@@ -463,7 +489,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) {
}
}
- case reflect.TypeOf(NodeReference[[]ValueReference[*yaml.Node]]{}):
+ case setFieldNodeReferenceSliceValueReferenceYAMLNodeType:
if utils.IsNodeArray(valueNode) {
if field.CanSet() {
diff --git a/datamodel/low/node_lines_test.go b/datamodel/low/node_lines_test.go
new file mode 100644
index 00000000..cfea56b0
--- /dev/null
+++ b/datamodel/low/node_lines_test.go
@@ -0,0 +1,97 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package low
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/pb33f/testify/assert"
+ "go.yaml.in/yaml/v4"
+)
+
+// writeLines applies the same writes to a NodeLines: a stored single node that a later add turns into a
+// pair, a line built up by adds, a stored value that a later add leaves alone, and a stored slice that a
+// later Store replaces.
+func writeLines(n *NodeLines, a, b, c *yaml.Node) {
+ n.Store(1, a)
+ n.add(1, b)
+ n.add(2, a)
+ n.add(2, b)
+ n.add(2, c)
+ n.Store(3, "not a node")
+ n.add(3, a)
+ n.Store(4, []*yaml.Node{a})
+ n.Store(4, []*yaml.Node{c})
+}
+
+// Writes give the same result whether they are recorded before the line index is built or applied to it.
+func TestNodeLines_WritesBeforeAndAfterIndexing(t *testing.T) {
+ a, b, c := &yaml.Node{Value: "a"}, &yaml.Node{Value: "b"}, &yaml.Node{Value: "c"}
+
+ var recorded NodeLines
+ writeLines(&recorded, a, b, c)
+
+ var applied NodeLines
+ _, ok := applied.Load(1) // builds the empty index, so every write below is applied directly
+ assert.False(t, ok)
+ writeLines(&applied, a, b, c)
+
+ for _, n := range []*NodeLines{&recorded, &applied} {
+ one, _ := n.Load(1)
+ assert.Equal(t, []*yaml.Node{a, b}, one)
+ two, _ := n.Load(2)
+ assert.Equal(t, []*yaml.Node{a, b, c}, two)
+ three, _ := n.Load(3)
+ assert.Equal(t, "not a node", three)
+ four, _ := n.Load(4)
+ assert.Equal(t, []*yaml.Node{c}, four)
+ }
+}
+
+// Range visits lines in order, stops when asked, and lets the callback write to the nodes it ranges over.
+func TestNodeLines_Range(t *testing.T) {
+ var n NodeLines
+ for _, line := range []int{30, 10, 20} {
+ n.Store(line, &yaml.Node{Line: line})
+ }
+
+ var visited []int
+ n.Range(func(line int, _ any) bool {
+ visited = append(visited, line)
+ n.Store(line+1, &yaml.Node{}) // writing mid-range must not deadlock
+ return line < 20
+ })
+ assert.Equal(t, []int{10, 20}, visited)
+
+ _, written := n.Load(11)
+ assert.True(t, written)
+}
+
+// NodeLines is safe for concurrent writes and reads.
+func TestNodeLines_Concurrent(t *testing.T) {
+ var n NodeLines
+ var wg sync.WaitGroup
+ for g := 0; g < 8; g++ {
+ wg.Add(1)
+ go func(g int) {
+ defer wg.Done()
+ for i := 0; i < 100; i++ {
+ n.add(i, &yaml.Node{Line: i})
+ if i%10 == g {
+ n.Load(i)
+ }
+ }
+ }(g)
+ }
+ wg.Wait()
+
+ lines := 0
+ n.Range(func(_ int, value any) bool {
+ assert.Len(t, value, 8)
+ lines++
+ return true
+ })
+ assert.Equal(t, 100, lines)
+}
diff --git a/datamodel/low/node_map.go b/datamodel/low/node_map.go
index b2b1d8bb..99dc13bd 100644
--- a/datamodel/low/node_map.go
+++ b/datamodel/low/node_map.go
@@ -6,6 +6,7 @@ package low
import (
"context"
+ "slices"
"sync"
"github.com/pb33f/libopenapi/orderedmap"
@@ -22,38 +23,130 @@ type AddNodes interface {
AddNode(key int, node *yaml.Node)
}
+// NodeLines holds the yaml nodes of a model object by line number. A line holds a *yaml.Node, or a
+// []*yaml.Node when several nodes share it (as they do in JSON).
+//
+// Node maps are filled for every object built but read only by tools that map lines back to objects, so
+// writes are recorded as they happen and the line index is built when the nodes are first read.
+// NodeLines is safe for concurrent use, and its zero value is ready to use.
+type NodeLines struct {
+ mu sync.Mutex
+ pending []nodeLineWrite
+ lines map[int]any
+}
+
+type nodeLineWrite struct {
+ line int
+ value any
+ add bool // add the node to the line rather than replace the line's value
+}
+
+// Store sets the value held for a line.
+func (n *NodeLines) Store(line int, value any) {
+ n.write(nodeLineWrite{line: line, value: value})
+}
+
+// Load returns the value held for a line, and whether the line holds one.
+func (n *NodeLines) Load(line int) (any, bool) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ value, ok := n.index()[line]
+ return value, ok
+}
+
+// Range calls f for each line and its value, in line order, until f returns false. The lines are read
+// before f is first called, so f may write to the nodes.
+func (n *NodeLines) Range(f func(line int, value any) bool) {
+ n.mu.Lock()
+ index := n.index()
+ lines := make([]int, 0, len(index))
+ for line := range index {
+ lines = append(lines, line)
+ }
+ slices.Sort(lines)
+ values := make([]any, len(lines))
+ for i, line := range lines {
+ values[i] = index[line]
+ }
+ n.mu.Unlock()
+ for i, line := range lines {
+ if !f(line, values[i]) {
+ return
+ }
+ }
+}
+
+// add appends a node to a line.
+func (n *NodeLines) add(line int, node *yaml.Node) {
+ n.write(nodeLineWrite{line: line, value: node, add: true})
+}
+
+func (n *NodeLines) write(w nodeLineWrite) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ if n.lines != nil {
+ n.apply(w)
+ return
+ }
+ if n.pending == nil {
+ n.pending = make([]nodeLineWrite, 0, 8)
+ }
+ n.pending = append(n.pending, w)
+}
+
+// index builds the line index from the recorded writes, once. The caller holds the lock.
+func (n *NodeLines) index() map[int]any {
+ if n.lines == nil {
+ n.lines = make(map[int]any, len(n.pending))
+ for _, w := range n.pending {
+ n.apply(w)
+ }
+ n.pending = nil
+ }
+ return n.lines
+}
+
+func (n *NodeLines) apply(w nodeLineWrite) {
+ if !w.add {
+ n.lines[w.line] = w.value
+ return
+ }
+ node := w.value.(*yaml.Node)
+ existing, ok := n.lines[w.line]
+ if !ok {
+ n.lines[w.line] = []*yaml.Node{node}
+ return
+ }
+ switch ext := existing.(type) {
+ case *yaml.Node:
+ n.lines[w.line] = []*yaml.Node{ext, node}
+ case []*yaml.Node:
+ n.lines[w.line] = append(ext, node)
+ }
+}
+
// NodeMap represents a map of yaml nodes
type NodeMap struct {
- // Nodes is a sync map of nodes for this object, and the key is the line number of the node
- // a line can contain many nodes (in JSON), so the value is a slice of *yaml.Node
- Nodes *sync.Map `yaml:"-" json:"-"`
+ // Nodes holds the nodes of this object by line number. A line can contain many nodes (in JSON), so
+ // a line's value is a *yaml.Node or a []*yaml.Node.
+ Nodes *NodeLines `yaml:"-" json:"-"`
}
// AddNode will add a node to the NodeMap
func (nm *NodeMap) AddNode(key int, node *yaml.Node) {
- if existing, ok := nm.Nodes.Load(key); ok {
- if ext, ko := existing.(*yaml.Node); ko {
- nm.Nodes.Store(key, []*yaml.Node{ext, node})
- }
- if ext, ko := existing.([]*yaml.Node); ko {
- ext = append(ext, node)
- nm.Nodes.Store(key, ext)
- }
- } else {
- nm.Nodes.Store(key, []*yaml.Node{node})
- }
+ nm.Nodes.add(key, node)
}
// GetNodes will return the map of nodes
func (nm *NodeMap) GetNodes() map[int][]*yaml.Node {
composed := make(map[int][]*yaml.Node)
if nm.Nodes != nil {
- nm.Nodes.Range(func(key, value interface{}) bool {
+ nm.Nodes.Range(func(line int, value any) bool {
if v, ok := value.([]*yaml.Node); ok {
- composed[key.(int)] = v
+ composed[line] = v
}
if v, ok := value.(*yaml.Node); ok {
- composed[key.(int)] = []*yaml.Node{v}
+ composed[line] = []*yaml.Node{v}
}
return true
@@ -95,9 +188,8 @@ func (nm *NodeMap) ContainsLine(line int) bool {
}
// ExtractNodes will extract all nodes from a yaml.Node and return them in a map
-func ExtractNodes(_ context.Context, root *yaml.Node) *sync.Map {
- var syncMap sync.Map
- nm := &NodeMap{Nodes: &syncMap}
+func ExtractNodes(_ context.Context, root *yaml.Node) *NodeLines {
+ nm := &NodeMap{Nodes: &NodeLines{}}
if root != nil && len(root.Content) > 0 {
nm.ExtractNodes(root, false)
} else {
@@ -111,9 +203,8 @@ func ExtractNodes(_ context.Context, root *yaml.Node) *sync.Map {
// ExtractNodesRecursive will extract all nodes from a yaml.Node and return them in a map, just like ExtractNodes
// however, this version will dive-down the tree and extract all nodes from all child nodes as well until the tree
// is done.
-func ExtractNodesRecursive(_ context.Context, root *yaml.Node) *sync.Map {
- var syncMap sync.Map
- nm := &NodeMap{Nodes: &syncMap}
+func ExtractNodesRecursive(_ context.Context, root *yaml.Node) *NodeLines {
+ nm := &NodeMap{Nodes: &NodeLines{}}
nm.ExtractNodes(root, true)
return nm.Nodes
}
@@ -121,16 +212,15 @@ func ExtractNodesRecursive(_ context.Context, root *yaml.Node) *sync.Map {
// ExtractExtensionNodes will extract all extension nodes from a map of extensions, recursively.
func ExtractExtensionNodes(_ context.Context,
extensionMap *orderedmap.Map[KeyReference[string],
- ValueReference[*yaml.Node]], nodeMap *sync.Map,
+ ValueReference[*yaml.Node]], nodeMap *NodeLines,
) {
// range over the extension map and extract all nodes
for k, v := range extensionMap.FromOldest() {
results := []*yaml.Node{k.KeyNode}
- var newNodeMap sync.Map
- nm := &NodeMap{Nodes: &newNodeMap}
+ nm := &NodeMap{Nodes: &NodeLines{}}
if len(v.ValueNode.Content) > 0 {
nm.ExtractNodes(v.ValueNode, true)
- nm.Nodes.Range(func(key, value interface{}) bool {
+ nm.Nodes.Range(func(_ int, value any) bool {
for _, n := range value.([]*yaml.Node) {
results = append(results, n)
}
diff --git a/datamodel/low/node_map_merge.go b/datamodel/low/node_map_merge.go
index ec6794d5..73bc739b 100644
--- a/datamodel/low/node_map_merge.go
+++ b/datamodel/low/node_map_merge.go
@@ -4,14 +4,12 @@
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) {
+func MergeRecursiveNodesIfLineAbsent(dst *NodeLines, node *yaml.Node) {
if dst == nil || node == nil {
return
}
diff --git a/datamodel/low/node_map_merge_test.go b/datamodel/low/node_map_merge_test.go
index 9635fcc0..f9bf2610 100644
--- a/datamodel/low/node_map_merge_test.go
+++ b/datamodel/low/node_map_merge_test.go
@@ -4,7 +4,6 @@
package low
import (
- "sync"
"testing"
"github.com/pb33f/testify/assert"
@@ -29,7 +28,7 @@ func TestNodeMapMergeHelpers(t *testing.T) {
require.NoError(t, yaml.Unmarshal([]byte("example:\n nested:\n value: ok\n"), &root))
node := root.Content[0]
- var dst sync.Map
+ var dst NodeLines
blockedLine := node.Content[0].Line
dst.Store(blockedLine, []*yaml.Node{{Value: "existing"}})
@@ -39,8 +38,8 @@ func TestNodeMapMergeHelpers(t *testing.T) {
assert.True(t, blocked)
var foundNested bool
- dst.Range(func(key, value any) bool {
- if key.(int) == node.Content[1].Content[0].Line {
+ dst.Range(func(key int, value any) bool {
+ if key == node.Content[1].Content[0].Line {
foundNested = true
}
assert.NotNil(t, value)
diff --git a/datamodel/low/node_map_test.go b/datamodel/low/node_map_test.go
index a16b925d..f60f0b96 100644
--- a/datamodel/low/node_map_test.go
+++ b/datamodel/low/node_map_test.go
@@ -4,7 +4,6 @@
package low
import (
- "sync"
"testing"
"github.com/pb33f/testify/assert"
@@ -22,7 +21,7 @@ four:
var root yaml.Node
_ = yaml.Unmarshal([]byte(yml), &root)
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
nm.ExtractNodes(root.Content[0], false)
testTheThing(t, nm)
@@ -30,7 +29,7 @@ four:
func testTheThing(t *testing.T, nm *NodeMap) {
count := 0
- nm.Nodes.Range(func(key, value interface{}) bool {
+ nm.Nodes.Range(func(key int, value any) bool {
count++
return true
})
@@ -52,7 +51,7 @@ func testTheThing(t *testing.T, nm *NodeMap) {
assert.Equal(t, "four", nodes[4][0].Value)
}
-func testTheThingUnmarshalled(t *testing.T, nm *sync.Map) {
+func testTheThingUnmarshalled(t *testing.T, nm *NodeLines) {
n := &NodeMap{Nodes: nm}
nodes := n.GetNodes()
@@ -84,7 +83,7 @@ four:
nm := ExtractNodes(nil, root.Content[0])
count := 0
- nm.Range(func(key, value interface{}) bool {
+ nm.Range(func(key int, value any) bool {
count++
return true
})
@@ -108,7 +107,7 @@ four:
nm := ExtractNodesRecursive(nil, root.Content[0])
count := 0
- nm.Range(func(key, value interface{}) bool {
+ nm.Range(func(key int, value any) bool {
count++
return true
})
@@ -118,12 +117,12 @@ four:
}
func TestExtractNodes_Nil(t *testing.T) {
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
nm.ExtractNodes(nil, false)
count := 0
- nm.Nodes.Range(func(key, value interface{}) bool {
+ nm.Nodes.Range(func(key int, value any) bool {
count++
return true
})
@@ -142,7 +141,7 @@ four:
var root yaml.Node
_ = yaml.Unmarshal([]byte(yml), &root)
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
syncMap.Store(1, root.Content[0])
@@ -151,7 +150,7 @@ four:
}
func Test_NodeMapGetNodes_SingleNode(t *testing.T) {
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
syncMap.Store(1, &yaml.Node{})
@@ -171,7 +170,7 @@ four:
var root yaml.Node
_ = yaml.Unmarshal([]byte(yml), &root)
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
nm.ExtractNodes(root.Content[0], true)
@@ -185,7 +184,7 @@ four:
}
func Test_NodeMapGetNodes_EmptyNode(t *testing.T) {
- var syncMap sync.Map
+ var syncMap NodeLines
nm := &NodeMap{Nodes: &syncMap}
ex := nm.GetNodes()
@@ -206,11 +205,11 @@ x-rice:
_ = yaml.Unmarshal([]byte(yml), &root)
extensions := ExtractExtensions(root.Content[0])
- var syncMap sync.Map
+ var syncMap NodeLines
ExtractExtensionNodes(nil, extensions, &syncMap)
count := 0
- syncMap.Range(func(key, value interface{}) bool {
+ syncMap.Range(func(key int, value any) bool {
count++
return true
})
@@ -227,7 +226,7 @@ func TestExtractNodes_NoContent(t *testing.T) {
nm := ExtractNodes(nil, root.Content[0])
count := 0
- nm.Range(func(key, value interface{}) bool {
+ nm.Range(func(key int, value any) bool {
count++
return true
})
diff --git a/datamodel/low/reference.go b/datamodel/low/reference.go
index c7d1cc19..47a1d808 100644
--- a/datamodel/low/reference.go
+++ b/datamodel/low/reference.go
@@ -122,8 +122,6 @@ type NodeReference[T any] struct {
// The yaml.Node that is the key, that contains the value.
KeyNode *yaml.Node
-
- Context context.Context
}
var _ HasValueNodeUntyped = &NodeReference[any]{}
diff --git a/datamodel/low/v3/callback.go b/datamodel/low/v3/callback.go
index 1642b376..2a29e5e2 100644
--- a/datamodel/low/v3/callback.go
+++ b/datamodel/low/v3/callback.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/orderedmap"
"github.com/pb33f/libopenapi/utils"
@@ -30,7 +29,7 @@ type Callback struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -77,7 +76,7 @@ func (cb *Callback) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in
root = utils.NodeAlias(root)
cb.RootNode = root
utils.CheckForMergeNodes(root)
- cb.nodeStore = sync.Map{}
+ cb.nodeStore = low.NodeLines{}
cb.Nodes = &cb.nodeStore
if len(root.Content) > 0 {
cb.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/components.go b/datamodel/low/v3/components.go
index ce6dbdb0..65974ba6 100644
--- a/datamodel/low/v3/components.go
+++ b/datamodel/low/v3/components.go
@@ -42,7 +42,7 @@ type Components struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -175,7 +175,7 @@ func (co *Components) Build(ctx context.Context, root *yaml.Node, idx *index.Spe
utils.CheckForMergeNodes(root)
co.reference = low.Reference{}
co.Reference = &co.reference
- co.nodeStore = sync.Map{}
+ co.nodeStore = low.NodeLines{}
co.Nodes = &co.nodeStore
if len(root.Content) > 0 {
co.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/components_test.go b/datamodel/low/v3/components_test.go
index 2c6e48fe..a6d2c1e7 100644
--- a/datamodel/low/v3/components_test.go
+++ b/datamodel/low/v3/components_test.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"fmt"
- "sync"
"testing"
"github.com/pb33f/libopenapi/datamodel/low"
@@ -273,7 +272,7 @@ components:
idx := index.NewSpecIndex(&idxNode)
_, _, compNode := utils.FindKeyNodeFullTop(ComponentsLabel, idxNode.Content[0].Content)
- var nodeStore sync.Map
+ var nodeStore low.NodeLines
components := &Components{}
components.Nodes = &nodeStore
diff --git a/datamodel/low/v3/encoding.go b/datamodel/low/v3/encoding.go
index 8f1d1dd7..e5f15399 100644
--- a/datamodel/low/v3/encoding.go
+++ b/datamodel/low/v3/encoding.go
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -28,7 +27,7 @@ type Encoding struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -88,7 +87,7 @@ func (en *Encoding) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in
root = utils.NodeAlias(root)
en.RootNode = root
utils.CheckForMergeNodes(root)
- en.nodeStore = sync.Map{}
+ en.nodeStore = low.NodeLines{}
en.Nodes = &en.nodeStore
if len(root.Content) > 0 {
en.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/header.go b/datamodel/low/v3/header.go
index 02929e25..76149443 100644
--- a/datamodel/low/v3/header.go
+++ b/datamodel/low/v3/header.go
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
@@ -36,7 +35,7 @@ type Header struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -138,7 +137,7 @@ func (h *Header) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index
root = utils.NodeAlias(root)
h.RootNode = root
utils.CheckForMergeNodes(root)
- h.nodeStore = sync.Map{}
+ h.nodeStore = low.NodeLines{}
h.Nodes = &h.nodeStore
if len(root.Content) > 0 {
h.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/link.go b/datamodel/low/v3/link.go
index 5e262c98..290f760b 100644
--- a/datamodel/low/v3/link.go
+++ b/datamodel/low/v3/link.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -39,7 +38,7 @@ type Link struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -91,7 +90,7 @@ func (l *Link) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.S
root = utils.NodeAlias(root)
l.RootNode = root
utils.CheckForMergeNodes(root)
- l.nodeStore = sync.Map{}
+ l.nodeStore = low.NodeLines{}
l.Nodes = &l.nodeStore
if len(root.Content) > 0 {
l.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/media_type.go b/datamodel/low/v3/media_type.go
index 122e027d..ef1d37b3 100644
--- a/datamodel/low/v3/media_type.go
+++ b/datamodel/low/v3/media_type.go
@@ -7,7 +7,6 @@ import (
"context"
"hash/maphash"
"slices"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
@@ -33,7 +32,7 @@ type MediaType struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -92,7 +91,7 @@ func (mt *MediaType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i
utils.CheckForMergeNodes(root)
mt.reference = low.Reference{}
mt.Reference = &mt.reference
- mt.nodeStore = sync.Map{}
+ mt.nodeStore = low.NodeLines{}
mt.Nodes = &mt.nodeStore
if len(root.Content) > 0 {
mt.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/oauth_flows.go b/datamodel/low/v3/oauth_flows.go
index 90dd3456..67ada564 100644
--- a/datamodel/low/v3/oauth_flows.go
+++ b/datamodel/low/v3/oauth_flows.go
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -29,7 +28,7 @@ type OAuthFlows struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -73,7 +72,7 @@ func (o *OAuthFlows) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i
utils.CheckForMergeNodes(root)
o.reference = low.Reference{}
o.Reference = &o.reference
- o.nodeStore = sync.Map{}
+ o.nodeStore = low.NodeLines{}
o.Nodes = &o.nodeStore
if len(root.Content) > 0 {
o.NodeMap.ExtractNodes(root, false)
@@ -159,7 +158,7 @@ type OAuthFlow struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -199,7 +198,7 @@ func (o *OAuthFlow) GetRootNode() *yaml.Node {
func (o *OAuthFlow) Build(ctx context.Context, _, root *yaml.Node, idx *index.SpecIndex) error {
o.reference = low.Reference{}
o.Reference = &o.reference
- o.nodeStore = sync.Map{}
+ o.nodeStore = low.NodeLines{}
o.Nodes = &o.nodeStore
if len(root.Content) > 0 {
o.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/operation.go b/datamodel/low/v3/operation.go
index e54657d6..e9d54aff 100644
--- a/datamodel/low/v3/operation.go
+++ b/datamodel/low/v3/operation.go
@@ -7,7 +7,6 @@ import (
"context"
"hash/maphash"
"sort"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
@@ -40,7 +39,7 @@ type Operation struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -92,7 +91,7 @@ func (o *Operation) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in
utils.CheckForMergeNodes(root)
o.reference = low.Reference{}
o.Reference = &o.reference
- o.nodeStore = sync.Map{}
+ o.nodeStore = low.NodeLines{}
o.Nodes = &o.nodeStore
if len(root.Content) > 0 {
o.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/parameter.go b/datamodel/low/v3/parameter.go
index 8cf942e2..8d542ce7 100644
--- a/datamodel/low/v3/parameter.go
+++ b/datamodel/low/v3/parameter.go
@@ -8,7 +8,6 @@ import (
"fmt"
"hash/maphash"
"slices"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
@@ -41,7 +40,7 @@ type Parameter struct {
Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -98,7 +97,7 @@ func (p *Parameter) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in
p.KeyNode = keyNode
p.RootNode = root
utils.CheckForMergeNodes(root)
- p.nodeStore = sync.Map{}
+ p.nodeStore = low.NodeLines{}
p.Nodes = &p.nodeStore
if len(root.Content) > 0 {
p.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/path_item.go b/datamodel/low/v3/path_item.go
index b80b1b6b..1038a09d 100644
--- a/datamodel/low/v3/path_item.go
+++ b/datamodel/low/v3/path_item.go
@@ -9,7 +9,6 @@ import (
"hash/maphash"
"sort"
"strings"
- "sync"
"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/datamodel/low"
@@ -45,7 +44,7 @@ type PathItem struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -206,7 +205,7 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
p.KeyNode = keyNode
p.RootNode = root
utils.CheckForMergeNodes(root)
- p.nodeStore = sync.Map{}
+ p.nodeStore = low.NodeLines{}
p.Nodes = &p.nodeStore
if len(root.Content) > 0 {
p.NodeMap.ExtractNodes(root, false)
@@ -222,6 +221,8 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
var currentNode *yaml.Node
ops := make([]low.NodeReference[*Operation], 0, len(root.Content)/2)
+ // the context each operation was resolved in, which its build runs with.
+ opContexts := make(map[*Operation]context.Context, len(root.Content)/2)
var additionalOps *orderedmap.Map[low.KeyReference[string], low.NodeReference[*Operation]]
var additionalOpsKeyNode, additionalOpsValueNode *yaml.Node
@@ -329,13 +330,13 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
Value: &op,
KeyNode: currentNode,
ValueNode: pathNode,
- Context: foundContext,
}
if opIsRef {
opRef.SetReference(opRefVal, opRefNode)
}
ops = append(ops, opRef)
+ opContexts[opRef.Value] = foundContext
if isStandardOp {
switch currentNode.Value {
@@ -388,11 +389,11 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
Value: &addOp,
KeyNode: opKeyNode,
ValueNode: opValueNode,
- Context: foundContext,
}
if opIsRef {
addOpRef.SetReference(opRefVal, opRefNode)
}
+ opContexts[addOpRef.Value] = foundContext
additionalOps.Set(low.KeyReference[string]{
KeyNode: opKeyNode,
@@ -425,7 +426,8 @@ func (p *PathItem) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
refNode = op.GetReferenceNode()
}
- err := op.Value.Build(op.Context, op.KeyNode, op.ValueNode, op.Context.Value(index.FoundIndexKey).(*index.SpecIndex))
+ opCtx := opContexts[op.Value]
+ err := op.Value.Build(opCtx, op.KeyNode, op.ValueNode, opCtx.Value(index.FoundIndexKey).(*index.SpecIndex))
if ref != "" {
op.Value.Reference.SetReference(ref, refNode)
}
diff --git a/datamodel/low/v3/paths.go b/datamodel/low/v3/paths.go
index 14824283..6868d798 100644
--- a/datamodel/low/v3/paths.go
+++ b/datamodel/low/v3/paths.go
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/datamodel/low"
@@ -30,7 +29,7 @@ type Paths struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -97,7 +96,7 @@ func (p *Paths) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.
utils.CheckForMergeNodes(root)
p.reference = low.Reference{}
p.Reference = &p.reference
- p.nodeStore = sync.Map{}
+ p.nodeStore = low.NodeLines{}
p.Nodes = &p.nodeStore
if keyNode != nil {
p.AddNode(keyNode.Line, keyNode)
diff --git a/datamodel/low/v3/request_body.go b/datamodel/low/v3/request_body.go
index 344822e2..12b0e3aa 100644
--- a/datamodel/low/v3/request_body.go
+++ b/datamodel/low/v3/request_body.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -26,7 +25,7 @@ type RequestBody struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -78,7 +77,7 @@ func (rb *RequestBody) Build(ctx context.Context, keyNode, root *yaml.Node, idx
root = utils.NodeAlias(root)
rb.RootNode = root
utils.CheckForMergeNodes(root)
- rb.nodeStore = sync.Map{}
+ rb.nodeStore = low.NodeLines{}
rb.Nodes = &rb.nodeStore
if len(root.Content) > 0 {
rb.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/response.go b/datamodel/low/v3/response.go
index 15a94d46..61cc45d0 100644
--- a/datamodel/low/v3/response.go
+++ b/datamodel/low/v3/response.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -31,7 +30,7 @@ type Response struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -93,7 +92,7 @@ func (r *Response) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind
root = utils.NodeAlias(root)
r.RootNode = root
utils.CheckForMergeNodes(root)
- r.nodeStore = sync.Map{}
+ r.nodeStore = low.NodeLines{}
r.Nodes = &r.nodeStore
if len(root.Content) > 0 {
r.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/responses.go b/datamodel/low/v3/responses.go
index b7891e9b..08df233f 100644
--- a/datamodel/low/v3/responses.go
+++ b/datamodel/low/v3/responses.go
@@ -8,7 +8,6 @@ import (
"fmt"
"hash/maphash"
"strings"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -43,7 +42,7 @@ type Responses struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -81,7 +80,7 @@ func (r *Responses) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in
r.RootNode = root
r.reference = low.Reference{}
r.Reference = &r.reference
- r.nodeStore = sync.Map{}
+ r.nodeStore = low.NodeLines{}
r.Nodes = &r.nodeStore
if len(root.Content) > 0 {
r.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/low/v3/security_scheme.go b/datamodel/low/v3/security_scheme.go
index 66185deb..fa07fc33 100644
--- a/datamodel/low/v3/security_scheme.go
+++ b/datamodel/low/v3/security_scheme.go
@@ -6,7 +6,6 @@ package v3
import (
"context"
"hash/maphash"
- "sync"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/index"
@@ -41,7 +40,7 @@ type SecurityScheme struct {
RootNode *yaml.Node
index *index.SpecIndex
context context.Context
- nodeStore sync.Map
+ nodeStore low.NodeLines
reference low.Reference
*low.Reference
low.NodeMap
@@ -88,7 +87,7 @@ func (ss *SecurityScheme) Build(ctx context.Context, keyNode, root *yaml.Node, i
root = utils.NodeAlias(root)
ss.RootNode = root
utils.CheckForMergeNodes(root)
- ss.nodeStore = sync.Map{}
+ ss.nodeStore = low.NodeLines{}
ss.Nodes = &ss.nodeStore
if len(root.Content) > 0 {
ss.NodeMap.ExtractNodes(root, false)
diff --git a/datamodel/spec_info.go b/datamodel/spec_info.go
index ab947fd0..a6866773 100644
--- a/datamodel/spec_info.go
+++ b/datamodel/spec_info.go
@@ -14,6 +14,7 @@ import (
"unicode/utf16"
"unicode/utf8"
+ "github.com/pb33f/libopenapi/internal/jsonnode"
"github.com/pb33f/libopenapi/utils"
"go.yaml.in/yaml/v4"
)
@@ -188,7 +189,7 @@ func extractSpecInfoInternal(spec []byte, bypass bool, skipJSON bool) (*SpecInfo
parseBytes = normalizeJSONForYAMLParser(spec)
}
- err := yaml.Unmarshal(parseBytes, &parsedSpec)
+ err := jsonnode.Unmarshal(parseBytes, &parsedSpec)
if err != nil {
if !bypass {
return nil, fmt.Errorf("unable to parse specification: %s", err.Error())
diff --git a/index/rolodex_file_loader.go b/index/rolodex_file_loader.go
index 3a2547ec..1fea52bd 100644
--- a/index/rolodex_file_loader.go
+++ b/index/rolodex_file_loader.go
@@ -19,6 +19,7 @@ import (
"context"
"github.com/pb33f/libopenapi/datamodel"
+ "github.com/pb33f/libopenapi/internal/jsonnode"
"github.com/pb33f/libopenapi/utils"
"go.yaml.in/yaml/v4"
)
@@ -293,7 +294,7 @@ func (l *LocalFile) GetContentAsYAMLNode() (*yaml.Node, error) {
return nil, fmt.Errorf("no data to parse for file: %s", l.fullPath)
}
var root yaml.Node
- err := yaml.Unmarshal(l.data, &root)
+ err := jsonnode.Unmarshal(l.data, &root)
if err != nil {
// we can't parse it, so create a fake document node with a single string content
root = yaml.Node{
diff --git a/index/rolodex_remote_loader.go b/index/rolodex_remote_loader.go
index 7f9b0144..818997a0 100644
--- a/index/rolodex_remote_loader.go
+++ b/index/rolodex_remote_loader.go
@@ -21,6 +21,7 @@ import (
"time"
"github.com/pb33f/libopenapi/datamodel"
+ "github.com/pb33f/libopenapi/internal/jsonnode"
"github.com/pb33f/libopenapi/utils"
"go.yaml.in/yaml/v4"
@@ -307,7 +308,7 @@ func (f *RemoteFile) GetContentAsYAMLNode() (*yaml.Node, error) {
return nil, fmt.Errorf("no data to parse for file: %s", f.fullPath)
}
var root yaml.Node
- err := yaml.Unmarshal(f.data, &root)
+ err := jsonnode.Unmarshal(f.data, &root)
if err != nil {
return nil, err
diff --git a/internal/jsonnode/jsonnode.go b/internal/jsonnode/jsonnode.go
new file mode 100644
index 00000000..0fc56024
--- /dev/null
+++ b/internal/jsonnode/jsonnode.go
@@ -0,0 +1,441 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+// Package jsonnode builds the yaml.Node tree for a JSON document directly, without running yaml's scanner.
+//
+// yaml parses JSON as a flow collection, and its scanner treats the opening '{' as a potential simple key,
+// so it buffers every token of the document until the closing '}'. For a large JSON specification that is
+// hundreds of megabytes of token queue. JSON's grammar is small enough to parse directly into the exact
+// tree yaml would produce: the same kinds, tags, styles, values, lines and columns.
+//
+// Parse only accepts input whose yaml parse it reproduces exactly. Anything else (invalid JSON, or valid
+// JSON that yaml reads differently, such as a key separated from its colon by a line break) is declined,
+// and the caller parses it with yaml instead, getting yaml's result or yaml's error.
+package jsonnode
+
+import (
+ "regexp"
+ "strconv"
+ "unicode/utf8"
+
+ "go.yaml.in/yaml/v4"
+)
+
+const (
+ // maxDepth bounds nesting well inside yaml's own depth limit.
+ maxDepth = 1000
+
+ // internLimit is the longest string interned. Keys and short values repeat heavily across a
+ // specification; long text rarely does and is not worth hashing.
+ internLimit = 64
+
+ nodeSlabSize = 512
+ contentSlabSize = 2048
+)
+
+// yamlStyleFloat is the pattern yaml's resolver requires of a plain scalar before resolving it as a float.
+var yamlStyleFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`)
+
+type parser struct {
+ data []byte
+ pos int
+ line int // 1-based line of data[pos]
+ col int // 1-based column of data[pos], counted in characters as yaml counts them
+
+ depth int
+ nodes []yaml.Node // slab the tree's nodes are allocated from
+ content []*yaml.Node // slab the collections' Content slices are carved from
+ stack []*yaml.Node // children of the collections still open
+ strs map[string]string
+ buf []byte // scratch space for decoding escaped strings
+}
+
+// Unmarshal decodes data into out, producing exactly what yaml.Unmarshal produces: a JSON object is built
+// by Parse, and anything Parse declines is decoded by yaml.Unmarshal.
+func Unmarshal(data []byte, out *yaml.Node) error {
+ if doc, ok := Parse(data); ok {
+ *out = *doc
+ return nil
+ }
+ return yaml.Unmarshal(data, out)
+}
+
+// Parse returns the yaml document node yaml.Unmarshal produces for the JSON document in data, and true.
+// It returns false when data is not a JSON object whose yaml parse it reproduces exactly.
+func Parse(data []byte) (*yaml.Node, bool) {
+ p := parser{data: data, line: 1, col: 1}
+ if !p.skipSpace(false) || p.pos >= len(data) || data[p.pos] != '{' {
+ return nil, false
+ }
+ p.strs = make(map[string]string, 256)
+ doc := &yaml.Node{Kind: yaml.DocumentNode, Line: p.line, Column: p.col}
+ root, ok := p.parseValue()
+ if !ok || !p.skipSpace(false) || p.pos != len(data) {
+ return nil, false
+ }
+ doc.Content = []*yaml.Node{root}
+ return doc, true
+}
+
+func (p *parser) newNode(kind yaml.Kind, style yaml.Style, tag string) *yaml.Node {
+ if len(p.nodes) == cap(p.nodes) {
+ p.nodes = make([]yaml.Node, 0, nodeSlabSize)
+ }
+ p.nodes = append(p.nodes, yaml.Node{Kind: kind, Style: style, Tag: tag, Line: p.line, Column: p.col})
+ return &p.nodes[len(p.nodes)-1]
+}
+
+// carve returns a copy of children in a slice from the content slab. Its capacity equals its length, so
+// appending to a node's Content never writes into a neighbour's.
+func (p *parser) carve(children []*yaml.Node) []*yaml.Node {
+ n := len(children)
+ if cap(p.content)-len(p.content) < n {
+ p.content = make([]*yaml.Node, 0, max(contentSlabSize, n))
+ }
+ start := len(p.content)
+ p.content = append(p.content, children...)
+ return p.content[start : start+n : start+n]
+}
+
+func (p *parser) intern(b []byte) string {
+ if len(b) > internLimit {
+ return string(b)
+ }
+ if s, ok := p.strs[string(b)]; ok {
+ return s
+ }
+ s := string(b)
+ p.strs[s] = s
+ return s
+}
+
+// skipSpace consumes JSON whitespace. Outside any collection yaml does not accept a tab, so one declines.
+func (p *parser) skipSpace(inCollection bool) bool {
+ for p.pos < len(p.data) {
+ switch p.data[p.pos] {
+ case ' ':
+ p.pos++
+ p.col++
+ case '\t':
+ if !inCollection {
+ return false
+ }
+ p.pos++
+ p.col++
+ case '\n':
+ p.pos++
+ p.line++
+ p.col = 1
+ case '\r':
+ // yaml reads CR LF as one line break; a lone CR is declined.
+ if p.pos+1 >= len(p.data) || p.data[p.pos+1] != '\n' {
+ return false
+ }
+ p.pos += 2
+ p.line++
+ p.col = 1
+ default:
+ return true
+ }
+ }
+ return true
+}
+
+func (p *parser) parseValue() (*yaml.Node, bool) {
+ if p.pos >= len(p.data) {
+ return nil, false
+ }
+ switch c := p.data[p.pos]; {
+ case c == '{':
+ return p.parseCollection(yaml.MappingNode, "!!map", '}')
+ case c == '[':
+ return p.parseCollection(yaml.SequenceNode, "!!seq", ']')
+ case c == '"':
+ return p.parseString()
+ case c == 't':
+ return p.parseLiteral("true", "!!bool")
+ case c == 'f':
+ return p.parseLiteral("false", "!!bool")
+ case c == 'n':
+ return p.parseLiteral("null", "!!null")
+ case c == '-' || (c >= '0' && c <= '9'):
+ return p.parseNumber()
+ }
+ return nil, false
+}
+
+func (p *parser) parseCollection(kind yaml.Kind, tag string, closer byte) (*yaml.Node, bool) {
+ p.depth++
+ if p.depth > maxDepth {
+ return nil, false
+ }
+ node := p.newNode(kind, yaml.FlowStyle, tag)
+ p.pos++
+ p.col++
+ base := len(p.stack)
+ if !p.skipSpace(true) {
+ return nil, false
+ }
+ if p.pos < len(p.data) && p.data[p.pos] == closer {
+ p.pos++
+ p.col++
+ p.depth--
+ return node, true
+ }
+ for {
+ if kind == yaml.MappingNode {
+ if p.pos >= len(p.data) || p.data[p.pos] != '"' {
+ return nil, false
+ }
+ keyLine := p.line
+ key, ok := p.parseString()
+ if !ok || !p.skipSpace(true) {
+ return nil, false
+ }
+ // yaml only pairs a key with a colon on the same line.
+ if p.pos >= len(p.data) || p.data[p.pos] != ':' || p.line != keyLine {
+ return nil, false
+ }
+ p.pos++
+ p.col++
+ if !p.skipSpace(true) {
+ return nil, false
+ }
+ p.stack = append(p.stack, key)
+ }
+ value, ok := p.parseValue()
+ if !ok || !p.skipSpace(true) || p.pos >= len(p.data) {
+ return nil, false
+ }
+ p.stack = append(p.stack, value)
+ switch p.data[p.pos] {
+ case ',':
+ p.pos++
+ p.col++
+ if !p.skipSpace(true) {
+ return nil, false
+ }
+ continue
+ case closer:
+ p.pos++
+ p.col++
+ node.Content = p.carve(p.stack[base:])
+ clear(p.stack[base:])
+ p.stack = p.stack[:base]
+ p.depth--
+ return node, true
+ }
+ return nil, false
+ }
+}
+
+func (p *parser) parseLiteral(word, tag string) (*yaml.Node, bool) {
+ if len(p.data)-p.pos < len(word) || string(p.data[p.pos:p.pos+len(word)]) != word {
+ return nil, false
+ }
+ node := p.newNode(yaml.ScalarNode, 0, tag)
+ node.Value = word
+ p.pos += len(word)
+ p.col += len(word)
+ return node, true
+}
+
+func (p *parser) parseNumber() (*yaml.Node, bool) {
+ start := p.pos
+ i := p.pos
+ if p.data[i] == '-' {
+ i++
+ }
+ switch {
+ case i < len(p.data) && p.data[i] == '0':
+ i++
+ case i < len(p.data) && p.data[i] >= '1' && p.data[i] <= '9':
+ i = skipDigits(p.data, i)
+ default:
+ return nil, false
+ }
+ if i < len(p.data) && p.data[i] == '.' {
+ i++
+ if i >= len(p.data) || p.data[i] < '0' || p.data[i] > '9' {
+ return nil, false
+ }
+ i = skipDigits(p.data, i)
+ }
+ if i < len(p.data) && (p.data[i] == 'e' || p.data[i] == 'E') {
+ i++
+ if i < len(p.data) && (p.data[i] == '+' || p.data[i] == '-') {
+ i++
+ }
+ if i >= len(p.data) || p.data[i] < '0' || p.data[i] > '9' {
+ return nil, false
+ }
+ i = skipDigits(p.data, i)
+ }
+ value := p.intern(p.data[start:i])
+ node := p.newNode(yaml.ScalarNode, 0, numberTag(value))
+ node.Value = value
+ p.col += i - start
+ p.pos = i
+ return node, true
+}
+
+func skipDigits(data []byte, i int) int {
+ for i < len(data) && data[i] >= '0' && data[i] <= '9' {
+ i++
+ }
+ return i
+}
+
+// numberTag resolves a JSON number the way yaml's resolver resolves a plain scalar starting with a digit
+// or sign: negative zero is a float, then an integer when it parses as a signed or unsigned 64-bit
+// integer, a float when it parses as one, and a string otherwise (a float literal out of range).
+func numberTag(value string) string {
+ if value == "-0" || value == "-0.0" {
+ return "!!float"
+ }
+ if _, err := strconv.ParseInt(value, 0, 64); err == nil {
+ return "!!int"
+ }
+ if _, err := strconv.ParseUint(value, 0, 64); err == nil {
+ return "!!int"
+ }
+ if yamlStyleFloat.MatchString(value) {
+ if _, err := strconv.ParseFloat(value, 64); err == nil {
+ return "!!float"
+ }
+ }
+ return "!!str"
+}
+
+func (p *parser) parseString() (*yaml.Node, bool) {
+ node := p.newNode(yaml.ScalarNode, yaml.DoubleQuotedStyle, "!!str")
+ p.pos++
+ p.col++
+ start := p.pos
+ escaped := false
+ p.buf = p.buf[:0]
+ for {
+ if p.pos >= len(p.data) {
+ return nil, false
+ }
+ c := p.data[p.pos]
+ switch {
+ case c == '"':
+ if escaped {
+ node.Value = p.intern(p.buf)
+ } else {
+ node.Value = p.intern(p.data[start:p.pos])
+ }
+ p.pos++
+ p.col++
+ return node, true
+ case c == '\\':
+ if !escaped {
+ escaped = true
+ p.buf = append(p.buf, p.data[start:p.pos]...)
+ }
+ if !p.decodeEscape() {
+ return nil, false
+ }
+ case c >= 0x20 && c < 0x7F:
+ if escaped {
+ p.buf = append(p.buf, c)
+ }
+ p.pos++
+ p.col++
+ case c < 0x80:
+ // control characters and DEL: JSON forbids the former and yaml reads neither like JSON does.
+ return nil, false
+ default:
+ r, size := utf8.DecodeRune(p.data[p.pos:])
+ if !plainRune(r, size) {
+ return nil, false
+ }
+ if escaped {
+ p.buf = append(p.buf, p.data[p.pos:p.pos+size]...)
+ }
+ p.pos += size
+ p.col++
+ }
+ }
+}
+
+// plainRune reports whether yaml reads a non-ASCII character inside a quoted scalar as itself: valid
+// UTF-8 in the character set yaml accepts, and not one of the characters it treats as a line break or
+// byte order mark, or as a C1 control.
+func plainRune(r rune, size int) bool {
+ if r == utf8.RuneError && size == 1 {
+ return false
+ }
+ switch {
+ case r <= 0x9F: // C1 controls, including NEL
+ return false
+ case r == 0x2028 || r == 0x2029 || r == 0xFEFF:
+ return false
+ case r >= 0xFFFE && r <= 0xFFFF:
+ return false
+ }
+ return true
+}
+
+// decodeEscape decodes the JSON escape at p.pos into p.buf. yaml has no "\\/" escape, so that one is declined.
+func (p *parser) decodeEscape() bool {
+ if p.pos+1 >= len(p.data) {
+ return false
+ }
+ var b byte
+ switch p.data[p.pos+1] {
+ case '"':
+ b = '"'
+ case '\\':
+ b = '\\'
+ case 'b':
+ b = '\b'
+ case 'f':
+ b = '\f'
+ case 'n':
+ b = '\n'
+ case 'r':
+ b = '\r'
+ case 't':
+ b = '\t'
+ case 'u':
+ if p.pos+6 > len(p.data) {
+ return false
+ }
+ code := rune(0)
+ for _, h := range p.data[p.pos+2 : p.pos+6] {
+ d, ok := hexValue(h)
+ if !ok {
+ return false
+ }
+ code = code<<4 | d
+ }
+ // yaml rejects an escaped surrogate; the caller's yaml parse reports it.
+ if code >= 0xD800 && code <= 0xDFFF {
+ return false
+ }
+ p.buf = utf8.AppendRune(p.buf, code)
+ p.pos += 6
+ p.col += 6
+ return true
+ default:
+ return false
+ }
+ p.buf = append(p.buf, b)
+ p.pos += 2
+ p.col += 2
+ return true
+}
+
+func hexValue(h byte) (rune, bool) {
+ switch {
+ case h >= '0' && h <= '9':
+ return rune(h - '0'), true
+ case h >= 'a' && h <= 'f':
+ return rune(h-'a') + 10, true
+ case h >= 'A' && h <= 'F':
+ return rune(h-'A') + 10, true
+ }
+ return 0, false
+}
diff --git a/internal/jsonnode/jsonnode_bench_test.go b/internal/jsonnode/jsonnode_bench_test.go
new file mode 100644
index 00000000..8bf7d6b5
--- /dev/null
+++ b/internal/jsonnode/jsonnode_bench_test.go
@@ -0,0 +1,44 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package jsonnode
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "go.yaml.in/yaml/v4"
+)
+
+func benchmarkSpec(b *testing.B) []byte {
+ b.Helper()
+ data, err := os.ReadFile(filepath.Join("..", "..", "test_specs", "docusignv3.1.json"))
+ if err != nil {
+ b.Fatal(err)
+ }
+ return data
+}
+
+func BenchmarkParse_DocuSign(b *testing.B) {
+ data := benchmarkSpec(b)
+ b.SetBytes(int64(len(data)))
+ b.ReportAllocs()
+ for b.Loop() {
+ if _, ok := Parse(data); !ok {
+ b.Fatal("declined")
+ }
+ }
+}
+
+func BenchmarkYAMLUnmarshal_DocuSign(b *testing.B) {
+ data := benchmarkSpec(b)
+ b.SetBytes(int64(len(data)))
+ b.ReportAllocs()
+ for b.Loop() {
+ var node yaml.Node
+ if err := yaml.Unmarshal(data, &node); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/jsonnode/jsonnode_test.go b/internal/jsonnode/jsonnode_test.go
new file mode 100644
index 00000000..71ee4ddb
--- /dev/null
+++ b/internal/jsonnode/jsonnode_test.go
@@ -0,0 +1,224 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package jsonnode
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+// sameTree reports the first difference between two node trees, comparing every field yaml sets.
+func sameTree(want, got *yaml.Node, path string) error {
+ switch {
+ case want.Kind != got.Kind:
+ return fmt.Errorf("%s: kind %v != %v", path, got.Kind, want.Kind)
+ case want.Style != got.Style:
+ return fmt.Errorf("%s: style %v != %v", path, got.Style, want.Style)
+ case want.Tag != got.Tag:
+ return fmt.Errorf("%s: tag %q != %q", path, got.Tag, want.Tag)
+ case want.Value != got.Value:
+ return fmt.Errorf("%s: value %q != %q", path, got.Value, want.Value)
+ case want.Line != got.Line || want.Column != got.Column:
+ return fmt.Errorf("%s: position %d:%d != %d:%d", path, got.Line, got.Column, want.Line, want.Column)
+ case want.Anchor != got.Anchor || want.Alias != got.Alias || want.Stream != got.Stream:
+ return fmt.Errorf("%s: anchor, alias or stream differ", path)
+ case want.HeadComment != got.HeadComment || want.LineComment != got.LineComment ||
+ want.FootComment != got.FootComment:
+ return fmt.Errorf("%s: comments differ", path)
+ case (want.Content == nil) != (got.Content == nil) || len(want.Content) != len(got.Content):
+ return fmt.Errorf("%s: content %d (nil %v) != %d (nil %v)", path,
+ len(got.Content), got.Content == nil, len(want.Content), want.Content == nil)
+ }
+ for i := range want.Content {
+ if err := sameTree(want.Content[i], got.Content[i], fmt.Sprintf("%s/%d", path, i)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// checkParse asserts the parser either declines the input or builds exactly the tree yaml builds.
+func checkParse(t testing.TB, data []byte) bool {
+ t.Helper()
+ got, ok := Parse(data)
+ if !ok {
+ return false
+ }
+ var want yaml.Node
+ require.NoError(t, yaml.Unmarshal(data, &want), "accepted input that yaml rejects: %q", data)
+ require.NoError(t, sameTree(&want, got, "doc"), "input: %q", data)
+ return true
+}
+
+func TestParse_MatchesYAML(t *testing.T) {
+ accepted := []string{
+ `{}`,
+ `{"a":1}`,
+ "\n\n {\"a\": {}, \"b\": [], \"c\": [1, -2.5e3, true, null, \"x\\u00e9\\n\"]}\n\n",
+ "{\r\n \"a\" : 1,\r\n \"b\": [\r\n 2\r\n ]\r\n}\r\n",
+ "{\n\t\"a\":\t1\n}",
+ `{"é": "ü", "日本": "語", "emoji": "😀", "x": "a\u00e9b"}`,
+ `{"esc": "\"\\\b\f\n\r\t\u0041\u00e9\u20AC\u0000"}`,
+ `{"n": [0, -0, -0.0, -0.00, 1, -1, 0.5, -0.5, 1e5, 1E5, 1e+5, 1e-5, 1.5E-10, 123456789012345678901234567890]}`,
+ `{"range": [9223372036854775807, 9223372036854775808, 18446744073709551615, 18446744073709551616, -9223372036854775808, -9223372036854775809]}`,
+ `{"inf": 1e400, "tiny": 1e-400}`,
+ `{"nested": {"deeper": {"deepest": [[[{"a": [null]}]]]}}}`,
+ `{"":""}`,
+ `{"a":"b" , "c" :"d"}`,
+ "{\"key\":\n \"value on the next line\"}",
+ `{"dup": 1, "dup": 2}`,
+ `{"#": "# not a comment", "&a": "*a", "!tag": "- item", "k": "a: b", "q": "'"}`,
+ `{"long": "` + strings.Repeat("long text ", 200) + `"}`,
+ `{"` + strings.Repeat("k", 2000) + `": 1}`,
+ `{"c1ok": "\u0085\u2028\u2029\ufeff\uffff"}`,
+ `{"del": "\u007f"}`,
+ }
+ for _, src := range accepted {
+ require.True(t, checkParse(t, []byte(src)), "expected the parser to accept %q", src)
+ }
+
+ declined := []string{
+ ``,
+ ` `,
+ `[]`,
+ `"string"`,
+ `{`,
+ `{"a"`,
+ `{"a":`,
+ `{"a":1`,
+ `{"a":1,`,
+ `{"a":1,}`,
+ `{"a" 1}`,
+ `{a: 1}`,
+ `{"a": 1} x`,
+ `{"a": 1} {}`,
+ "\t{}",
+ "{}\t",
+ "{\r}",
+ "{\"a\"\r: 1}",
+ "{\"a\":\r1}",
+ "{\"a\":1,\r\"b\":2}",
+ `{"a": "\u12`,
+ "{\"a\"\n: 1}",
+ `{"a": [1,]}`,
+ `{"a": [1 2]}`,
+ `{"a": tru}`,
+ `{"a": truex}`,
+ `{"a": nul}`,
+ `{"a": fals}`,
+ `{"a": -}`,
+ `{"a": 01}`,
+ `{"a": 1.}`,
+ `{"a": 1.e5}`,
+ `{"a": 1e}`,
+ `{"a": 1e+}`,
+ `{"a": .5}`,
+ `{"a": +1}`,
+ `{"a": 0x1F}`,
+ `{"a": x}`,
+ `{"a": "unterminated}`,
+ `{"a": "bad \x escape"}`,
+ `{"a": "solidus \/ escape"}`,
+ `{"a": "bad \u12 escape"}`,
+ `{"a": "bad \u12G4 escape"}`,
+ `{"a": "\ud83d\ude00"}`,
+ `{"a": "\udc00"}`,
+ "{\"a\": \"raw\ttab\"}",
+ "{\"a\": \"raw\nbreak\"}",
+ "{\"a\": \"del\x7f\"}",
+ "{\"a\": \"c1\u0085\"}",
+ "{\"a\": \"ls\xe2\x80\xa8\"}",
+ "{\"a\": \"ps\xe2\x80\xa9\"}",
+ "{\"a\": \"bom\xef\xbb\xbf\"}",
+ "{\"a\": \"nonchar\xef\xbf\xbf\"}",
+ "{\"a\": \"bad \xff utf8\"}",
+ "{\"a\": \"truncated \xe2\x82\"}",
+ `{"a": "trailing backslash\`,
+ strings.Repeat(`{"a":`, maxDepth+1) + "1" + strings.Repeat("}", maxDepth+1),
+ strings.Repeat(`{"a":[`, maxDepth/2) + "1" + strings.Repeat("]}", maxDepth/2) + "x",
+ }
+ for _, src := range declined {
+ _, ok := Parse([]byte(src))
+ require.False(t, ok, "expected the parser to decline %q", src)
+ }
+}
+
+// Every JSON fixture in the repository, and every YAML fixture re-encoded as JSON in several layouts,
+// parses to exactly the tree yaml builds.
+func TestParse_RepositoryFixtures(t *testing.T) {
+ root := filepath.Join("..", "..")
+ var jsonFiles, converted int
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ require.NoError(t, err)
+ if d.IsDir() && (d.Name() == ".git" || d.Name() == ".claude") {
+ return filepath.SkipDir
+ }
+ if d.IsDir() {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ switch filepath.Ext(path) {
+ case ".json":
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) > 0 && trimmed[0] == '{' && trimmed[len(trimmed)-1] == '}' {
+ require.True(t, checkParse(t, data), "declined fixture %s", path)
+ jsonFiles++
+ }
+ case ".yaml", ".yml":
+ var decoded map[string]any
+ if yaml.Unmarshal(data, &decoded) != nil || decoded == nil {
+ return nil
+ }
+ compact, err := json.Marshal(decoded)
+ if err != nil {
+ return nil // yaml values json cannot represent, such as maps with non-string keys
+ }
+ indented, err := json.MarshalIndent(decoded, "", " ")
+ require.NoError(t, err)
+ tabbed, err := json.MarshalIndent(decoded, "", "\t")
+ require.NoError(t, err)
+ for _, doc := range [][]byte{compact, indented, tabbed} {
+ require.True(t, checkParse(t, doc), "declined %s re-encoded as JSON", path)
+ }
+ converted++
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, jsonFiles, 10)
+ require.GreaterOrEqual(t, converted, 100)
+}
+
+// Unmarshal decodes JSON through Parse and everything else through yaml, matching yaml.Unmarshal either way.
+func TestUnmarshal(t *testing.T) {
+ for _, src := range []string{`{"a": [1, "b"]}`, "a: 1\nb: [2]\n", `{"a": 1} x`, `{a: 1}`, `{"a": "\/"}`} {
+ var want, got yaml.Node
+ wantErr := yaml.Unmarshal([]byte(src), &want)
+ gotErr := Unmarshal([]byte(src), &got)
+ require.Equal(t, wantErr, gotErr, "input %q", src)
+ require.NoError(t, sameTree(&want, &got, "doc"), "input %q", src)
+ }
+}
+
+func FuzzParse(f *testing.F) {
+ for _, seed := range []string{
+ `{}`, `{"a":1}`, `{"a":[1,2.5,-3e4,true,false,null,"s"]}`, `{"a":{"b":{"c":"\u00e9\n"}}}`,
+ "{\r\n\t\"a\" : \"b\"\r\n}", `{"a":"😀"}`, `{"":0}`,
+ } {
+ f.Add([]byte(seed))
+ }
+ f.Fuzz(func(t *testing.T, data []byte) {
+ checkParse(t, data)
+ })
+}
diff --git a/json/json.go b/json/json.go
index b223f741..7dc7c5af 100644
--- a/json/json.go
+++ b/json/json.go
@@ -1,9 +1,13 @@
package json
import (
+ "bytes"
"encoding/json"
"errors"
"fmt"
+ "math"
+ "strconv"
+ "unicode/utf8"
"github.com/pb33f/libopenapi/orderedmap"
"go.yaml.in/yaml/v4"
@@ -11,17 +15,362 @@ import (
// YAMLNodeToJSON converts yaml/json stored in a yaml.Node to json ordered matching the original yaml/json
func YAMLNodeToJSON(node *yaml.Node, indentation string) ([]byte, error) {
+ w := writer{}
+ if err := w.writeNode(node); err != nil {
+ return nil, err
+ }
+ if w.marshalFailed {
+ // a value encoding/json cannot represent (NaN or an infinity) is reported by marshaling the whole
+ // converted value, which is where and how that error has always surfaced.
+ return convertAndMarshal(node, indentation)
+ }
+ var out bytes.Buffer
+ _ = json.Indent(&out, w.buf, "", indentation) // w.buf is valid JSON by construction
+ return out.Bytes(), nil
+}
+
+// convertAndMarshal converts the node tree to ordered values and marshals them with encoding/json.
+func convertAndMarshal(node *yaml.Node, indentation string) ([]byte, error) {
c := converter{aliasesInFlight: make(map[*yaml.Node]struct{})}
v, err := c.handleYAMLNode(node)
if err != nil {
return nil, err
}
-
return json.MarshalIndent(v, "", indentation)
}
-// converter tracks alias targets currently being expanded, so a self-referencing anchor
-// (e.g. `a: &x [1, *x]`) is reported as an error instead of recursing forever.
+// dupScanLimit is the mapping size up to which repeated keys are found by scanning the keys written so
+// far; larger mappings track their keys in a set.
+const dupScanLimit = 16
+
+// writer writes the compact JSON that encoding/json produces for the ordered values the converter
+// builds, directly from the node tree. Values are written as json.Marshal writes them, so indenting
+// the result gives exactly what json.MarshalIndent gives for the converted value. Anything outside the
+// common shapes (scalars that need yaml's full decoding, complex keys, mappings with repeated keys) is
+// converted by the converter and marshaled by encoding/json, as before.
+type writer struct {
+ buf []byte
+ keys []string // keys of the mappings being written, for spotting repeated keys
+ aliasesInFlight map[*yaml.Node]struct{}
+ marshalFailed bool // a value could not be marshaled
+}
+
+func (w *writer) converter() converter {
+ if w.aliasesInFlight == nil {
+ w.aliasesInFlight = make(map[*yaml.Node]struct{})
+ }
+ return converter{aliasesInFlight: w.aliasesInFlight}
+}
+
+func (w *writer) writeNode(node *yaml.Node) error {
+ if node == nil {
+ return errors.New("nil yaml node")
+ }
+ switch node.Kind {
+ case yaml.DocumentNode:
+ if len(node.Content) == 0 {
+ return errors.New("empty yaml document")
+ }
+ return w.writeNode(node.Content[0])
+ case yaml.SequenceNode:
+ w.buf = append(w.buf, '[')
+ for i, n := range node.Content {
+ if i > 0 {
+ w.buf = append(w.buf, ',')
+ }
+ if err := w.writeNode(n); err != nil {
+ return err
+ }
+ }
+ w.buf = append(w.buf, ']')
+ return nil
+ case yaml.MappingNode:
+ return w.writeMapping(node)
+ case yaml.ScalarNode:
+ if b, ok := appendScalar(w.buf, node); ok {
+ w.buf = b
+ return nil
+ }
+ v, err := handleScalarNode(node)
+ if err != nil {
+ return err
+ }
+ w.writeMarshaled(v)
+ return nil
+ case yaml.AliasNode:
+ c := w.converter()
+ if _, inFlight := c.aliasesInFlight[node.Alias]; inFlight {
+ return fmt.Errorf("recursive alias '%s' at line %d, column %d", node.Value, node.Line, node.Column)
+ }
+ c.aliasesInFlight[node.Alias] = struct{}{}
+ defer delete(c.aliasesInFlight, node.Alias)
+ return w.writeNode(node.Alias)
+ default:
+ return fmt.Errorf("unknown node kind: %v", node.Kind)
+ }
+}
+
+// writeMarshaled writes a value converted from the nodes. A value that cannot be marshaled is noted
+// rather than returned: conversion errors anywhere in the tree take precedence over it.
+func (w *writer) writeMarshaled(v any) {
+ data, err := json.Marshal(v)
+ if err != nil {
+ w.marshalFailed = true
+ data = []byte("null")
+ }
+ w.buf = append(w.buf, data...)
+}
+
+func (w *writer) writeMapping(node *yaml.Node) error {
+ start, keysBase := len(w.buf), len(w.keys)
+ var seen map[string]struct{}
+ if len(node.Content)/2 > dupScanLimit {
+ seen = make(map[string]struct{}, len(node.Content)/2)
+ }
+ w.buf = append(w.buf, '{')
+ for i := 1; i < len(node.Content); i += 2 {
+ key, err := w.keyString(node.Content[i-1])
+ if err != nil {
+ return err
+ }
+ if w.repeatedKey(key, keysBase, seen) {
+ // a repeated key keeps its first position and takes the last value: the ordered map does that.
+ w.buf, w.keys = w.buf[:start], w.keys[:keysBase]
+ v, err := w.converter().handleMappingNode(node)
+ if err != nil {
+ return err
+ }
+ w.writeMarshaled(v)
+ return nil
+ }
+ if i > 1 {
+ w.buf = append(w.buf, ',')
+ }
+ w.buf = append(appendJSONString(w.buf, key), ':')
+ if err := w.writeNode(node.Content[i]); err != nil {
+ return err
+ }
+ }
+ w.keys = w.keys[:keysBase]
+ w.buf = append(w.buf, '}')
+ return nil
+}
+
+// repeatedKey reports whether key was already written in the current mapping, and records it.
+func (w *writer) repeatedKey(key string, keysBase int, seen map[string]struct{}) bool {
+ if seen != nil {
+ if _, ok := seen[key]; ok {
+ return true
+ }
+ seen[key] = struct{}{}
+ return false
+ }
+ for _, k := range w.keys[keysBase:] {
+ if k == key {
+ return true
+ }
+ }
+ w.keys = append(w.keys, key)
+ return false
+}
+
+// keyString returns the ordered map key the converter makes of a key node: a string key as it is, and
+// any other key as its JSON encoding.
+func (w *writer) keyString(keyNode *yaml.Node) (string, error) {
+ if keyNode != nil && keyNode.Kind == yaml.ScalarNode {
+ if keyNode.Tag == "!!str" {
+ return keyNode.Value, nil
+ }
+ var scratch [32]byte
+ if b, ok := appendScalar(scratch[:0], keyNode); ok {
+ return string(b), nil
+ }
+ }
+ kv, err := w.converter().handleYAMLNode(keyNode)
+ if err != nil {
+ return "", err
+ }
+ if key, isString := kv.(string); isString {
+ return key, nil
+ }
+ keyData, err := json.Marshal(kv)
+ if err != nil {
+ return "", err
+ }
+ return string(keyData), nil
+}
+
+// appendScalar appends the JSON encoding of the value yaml decodes a scalar node into, for the tags and
+// values whose decoding is known exactly, and reports false for anything else.
+func appendScalar(b []byte, n *yaml.Node) ([]byte, bool) {
+ switch n.Tag {
+ case "!!str":
+ return appendJSONString(b, n.Value), true
+ case "!!int":
+ // a canonical decimal in range decodes to an integer that encodes back to the same digits.
+ if isJSONInteger(n.Value) && n.Value != "-0" {
+ if _, err := strconv.ParseInt(n.Value, 10, 64); err == nil {
+ return append(b, n.Value...), true
+ }
+ }
+ case "!!bool":
+ switch n.Value {
+ case "true", "True", "TRUE":
+ return append(b, "true"...), true
+ case "false", "False", "FALSE":
+ return append(b, "false"...), true
+ }
+ case "!!null":
+ switch n.Value {
+ case "", "~", "null", "Null", "NULL":
+ return append(b, "null"...), true
+ }
+ case "!!float":
+ // yaml resolves -0 to negative zero, and an integer beyond int64 to an unsigned value it will
+ // not accept as a float; everything else in JSON number form decodes like ParseFloat.
+ if n.Value == "-0" || n.Value == "-0.0" || !isJSONNumber(n.Value) {
+ return b, false
+ }
+ if isJSONInteger(n.Value) {
+ if _, err := strconv.ParseInt(n.Value, 10, 64); err != nil {
+ return b, false
+ }
+ }
+ if f, err := strconv.ParseFloat(n.Value, 64); err == nil {
+ return appendJSONFloat(b, f), true
+ }
+ }
+ return b, false
+}
+
+// isJSONInteger reports whether s is a JSON integer: an optional minus and digits without a leading zero.
+func isJSONInteger(s string) bool {
+ return len(s) > 0 && jsonNumberEnd(s, false) == len(s)
+}
+
+// isJSONNumber reports whether s is a JSON number.
+func isJSONNumber(s string) bool {
+ return len(s) > 0 && jsonNumberEnd(s, true) == len(s)
+}
+
+// jsonNumberEnd returns the length of the JSON number (or integer, without fraction or exponent) at the
+// start of s, or -1 when s does not start with one.
+func jsonNumberEnd(s string, fractional bool) int {
+ i := 0
+ if s[i] == '-' {
+ i++
+ }
+ switch {
+ case i < len(s) && s[i] == '0':
+ i++
+ case i < len(s) && s[i] >= '1' && s[i] <= '9':
+ for i < len(s) && s[i] >= '0' && s[i] <= '9' {
+ i++
+ }
+ default:
+ return -1
+ }
+ if !fractional {
+ return i
+ }
+ if i < len(s) && s[i] == '.' {
+ i++
+ if i >= len(s) || s[i] < '0' || s[i] > '9' {
+ return -1
+ }
+ for i < len(s) && s[i] >= '0' && s[i] <= '9' {
+ i++
+ }
+ }
+ if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
+ i++
+ if i < len(s) && (s[i] == '+' || s[i] == '-') {
+ i++
+ }
+ if i >= len(s) || s[i] < '0' || s[i] > '9' {
+ return -1
+ }
+ for i < len(s) && s[i] >= '0' && s[i] <= '9' {
+ i++
+ }
+ }
+ return i
+}
+
+// appendJSONFloat appends a finite float64 formatted as encoding/json formats one.
+func appendJSONFloat(b []byte, f float64) []byte {
+ format := byte('f')
+ if abs := math.Abs(f); abs != 0 && (abs < 1e-6 || abs >= 1e21) {
+ format = 'e'
+ }
+ b = strconv.AppendFloat(b, f, format, -1, 64)
+ if format == 'e' {
+ // clean up e-09 to e-9
+ if n := len(b); n >= 4 && b[n-4] == 'e' && b[n-3] == '-' && b[n-2] == '0' {
+ b[n-2] = b[n-1]
+ b = b[:n-1]
+ }
+ }
+ return b
+}
+
+const hexDigits = "0123456789abcdef"
+
+// appendJSONString appends s as a JSON string, escaped as encoding/json's Marshal escapes it: quotes,
+// backslashes and control characters, the HTML characters <, > and &, U+2028 and U+2029, with invalid
+// UTF-8 replaced by U+FFFD.
+func appendJSONString(b []byte, s string) []byte {
+ b = append(b, '"')
+ start := 0
+ for i := 0; i < len(s); {
+ if c := s[i]; c < utf8.RuneSelf {
+ if c >= 0x20 && c != '"' && c != '\\' && c != '<' && c != '>' && c != '&' {
+ i++
+ continue
+ }
+ b = append(b, s[start:i]...)
+ switch c {
+ case '"', '\\':
+ b = append(b, '\\', c)
+ case '\b':
+ b = append(b, '\\', 'b')
+ case '\f':
+ b = append(b, '\\', 'f')
+ case '\n':
+ b = append(b, '\\', 'n')
+ case '\r':
+ b = append(b, '\\', 'r')
+ case '\t':
+ b = append(b, '\\', 't')
+ default:
+ b = append(b, '\\', 'u', '0', '0', hexDigits[c>>4], hexDigits[c&0xF])
+ }
+ i++
+ start = i
+ continue
+ }
+ r, size := utf8.DecodeRuneInString(s[i:])
+ switch {
+ case r == utf8.RuneError && size == 1:
+ b = append(b, s[start:i]...)
+ b = append(b, `\ufffd`...)
+ case r == '\u2028' || r == '\u2029':
+ b = append(b, s[start:i]...)
+ b = append(b, '\\', 'u', '2', '0', '2', hexDigits[r&0xF])
+ default:
+ i += size
+ continue
+ }
+ i += size
+ start = i
+ }
+ b = append(b, s[start:]...)
+ return append(b, '"')
+}
+
+// converter builds ordered values from a node tree for encoding/json to marshal. It tracks alias targets
+// currently being expanded, so a self-referencing anchor (e.g. `a: &x [1, *x]`) is reported as an error
+// instead of recursing forever.
type converter struct {
aliasesInFlight map[*yaml.Node]struct{}
}
diff --git a/json/json_writer_test.go b/json/json_writer_test.go
new file mode 100644
index 00000000..d2589390
--- /dev/null
+++ b/json/json_writer_test.go
@@ -0,0 +1,168 @@
+package json
+
+import (
+ "encoding/json"
+ "io/fs"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pb33f/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+// requireMatchesMarshal asserts YAMLNodeToJSON gives exactly what converting the tree and marshaling it
+// with encoding/json gives: the same bytes, or the same error.
+func requireMatchesMarshal(t testing.TB, node *yaml.Node, indentation string) {
+ t.Helper()
+ want, wantErr := convertAndMarshal(node, indentation)
+ got, gotErr := YAMLNodeToJSON(node, indentation)
+ if wantErr != nil {
+ require.EqualError(t, gotErr, wantErr.Error())
+ return
+ }
+ require.NoError(t, gotErr)
+ require.Equal(t, string(want), string(got))
+}
+
+func requireYAMLMatchesMarshal(t testing.TB, src string) {
+ t.Helper()
+ var node yaml.Node
+ if yaml.Unmarshal([]byte(src), &node) != nil {
+ return
+ }
+ for _, indentation := range []string{"", " ", "\t"} {
+ requireMatchesMarshal(t, &node, indentation)
+ }
+}
+
+// Every fixture in the repository converts exactly as encoding/json marshals it.
+func TestYAMLNodeToJSON_RepositoryFixtures(t *testing.T) {
+ var converted int
+ err := filepath.WalkDir(filepath.Join(".."), func(path string, d fs.DirEntry, err error) error {
+ require.NoError(t, err)
+ if d.IsDir() && (d.Name() == ".git" || d.Name() == ".claude") {
+ return filepath.SkipDir
+ }
+ switch filepath.Ext(path) {
+ case ".yaml", ".yml", ".json":
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+ requireYAMLMatchesMarshal(t, string(data))
+ converted++
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ require.Greater(t, converted, 100)
+}
+
+func TestYAMLNodeToJSON_MatchesMarshal(t *testing.T) {
+ var many strings.Builder
+ for i := 0; i < 3*dupScanLimit; i++ {
+ many.WriteString("k" + strings.Repeat("x", i) + ": 1\n")
+ }
+ manyWithRepeat := many.String() + "kxxx: 2\n"
+ for _, src := range []string{
+ "a: 1\nb: [1, 2.5, -3e4, true, false, null, s]\nc: {d: e}\n",
+ "html: '&'\ncontrol: \"\\b\\f\\n\\r\\t\\x01\\x7f\"\nsep: \"\\u2028\\u2029\"\n",
+ "unicode: \"é 日本 😀\"\n",
+ "ints: [0, -1, 123, 007, 0x1F, 0o17, 1_000, +5, -0, 9223372036854775807, 9223372036854775808, 18446744073709551616]\n",
+ "floats: [0.5, -0.5, 1e5, 1E-7, 1.0e21, 1e20, 123456789012345678901234567890, .5, 5., -0.0, 1e400, .inf, -.inf, .nan]\n",
+ "tagged: [!!int 5, !!int -0, !!int 1.5, !!float 5, !!float 9223372036854775808, !!float 1e400, !!float -0, !!float abc]\n",
+ "bools: [true, True, TRUE, false, False, FALSE, !!bool yes]\nnulls: [~, null, Null, NULL, !!null '', !!null x]\n",
+ "stamp: 2001-12-14\nstampt: 2001-12-14t21:59:43.10-05:00\nbin: !!binary aGVsbG8=\nbadbin: !!binary '@@'\n",
+ "strs: [!!str 5, '5', \"true\", !custom thing, !!str]\n",
+ "200: ok\n1.5: float\ntrue: bool\n~: null\n2001-12-14: date\n.nan: nan\n[a, b]: seq\n{x: y}: map\n",
+ "dup: 1\ndup: 2\nother: 3\n",
+ manyWithRepeat,
+ many.String(),
+ "base: &b {x: 1}\nuse: *b\nlist: &l [1, 2]\nagain: *l\n",
+ "<<: {merged: true}\nplain: 1\n",
+ "empty: {}\nemptyList: []\nnested: [[], {}]\n",
+ "just a scalar\n",
+ "- a\n- b\n",
+ "",
+ } {
+ requireYAMLMatchesMarshal(t, src)
+ }
+}
+
+// Node trees yaml.Unmarshal never builds: untagged scalars, broken structure and recursion.
+func TestYAMLNodeToJSON_ConstructedTrees(t *testing.T) {
+ scalar := func(tag, value string, style yaml.Style) *yaml.Node {
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value, Style: style}
+ }
+ mapping := func(content ...*yaml.Node) *yaml.Node {
+ return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: content}
+ }
+ recursive := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Anchor: "x"}
+ recursive.Content = []*yaml.Node{{Kind: yaml.AliasNode, Value: "x", Alias: recursive}}
+ recursiveKey := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ recursiveKey.Content = []*yaml.Node{{Kind: yaml.AliasNode, Value: "k", Alias: recursiveKey}, scalar("!!str", "v", 0)}
+ nanNode := scalar("!!float", ".nan", 0)
+ for _, node := range []*yaml.Node{
+ nil,
+ {Kind: yaml.DocumentNode},
+ {Kind: yaml.Kind(99)},
+ {Kind: yaml.DocumentNode, Content: []*yaml.Node{mapping(scalar("", "5", 0), scalar("", "true", 0))}},
+ mapping(scalar("", "quoted", yaml.DoubleQuotedStyle), scalar("tag:yaml.org,2002:str", "long", 0)),
+ mapping(nil, scalar("!!str", "v", 0)),
+ mapping(scalar("!!str", "k", 0), nil),
+ mapping(scalar("!!str", "odd", 0)),
+ mapping(scalar("!!str", "a", 0), nanNode, scalar("!!str", "b", 0), &yaml.Node{Kind: yaml.Kind(99)}),
+ mapping(nanNode, scalar("!!str", "nan key", 0)),
+ mapping(scalar("!!str", "a", 0), nanNode, scalar("!!str", "a", 0), scalar("!!str", "dup", 0)),
+ mapping(scalar("!!str", "a", 0), scalar("!!str", "1", 0), scalar("!!str", "a", 0), &yaml.Node{Kind: yaml.Kind(99)}),
+ {Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{scalar("!!int", "1", 0), nil}},
+ recursive,
+ recursiveKey,
+ nanNode,
+ {Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{
+ scalar("!!float", "1e", 0), scalar("!!float", "1.5e+", 0), scalar("!!float", "1.", 0)}},
+ } {
+ for _, indentation := range []string{"", " "} {
+ requireMatchesMarshal(t, node, indentation)
+ }
+ }
+}
+
+func FuzzYAMLNodeToJSON(f *testing.F) {
+ for _, seed := range []string{
+ "a: 1\n", "a: [1, 2.5, true, null, 'x']\n", "{\"a\": {\"b\": \"<&>\"}}", "200: x\n1.5: y\n",
+ "a: &x [1]\nb: *x\n", "a: 1\na: 2\n", "x: !!float 1e5\ny: !!int 7\n", "- 2001-12-14\n- !!binary aGk=\n",
+ } {
+ f.Add(seed)
+ }
+ f.Fuzz(func(t *testing.T, src string) {
+ requireYAMLMatchesMarshal(t, src)
+ })
+}
+
+// appendJSONString and appendJSONFloat write exactly what encoding/json writes.
+func FuzzAppendJSONString(f *testing.F) {
+ for _, seed := range []string{"", "plain", "", "\"\\\b\f\n\r\t\x00\x1f\x7f", "é😀
", "\xff\xfe bad"} {
+ f.Add(seed)
+ }
+ f.Fuzz(func(t *testing.T, s string) {
+ want, err := json.Marshal(s)
+ require.NoError(t, err)
+ require.Equal(t, string(want), string(appendJSONString(nil, s)))
+ })
+}
+
+func FuzzAppendJSONFloat(f *testing.F) {
+ for _, seed := range []float64{0, math.Copysign(0, -1), 1, -1.5, 1e-7, 1e-6, 1e20, 1e21, 123456789.125, math.MaxFloat64, math.SmallestNonzeroFloat64} {
+ f.Add(seed)
+ }
+ f.Fuzz(func(t *testing.T, v float64) {
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ return
+ }
+ want, err := json.Marshal(v)
+ require.NoError(t, err)
+ require.Equal(t, string(want), string(appendJSONFloat(nil, v)))
+ })
+}
diff --git a/orderedmap/builder.go b/orderedmap/builder.go
index 057a965a..100da2ef 100644
--- a/orderedmap/builder.go
+++ b/orderedmap/builder.go
@@ -97,7 +97,7 @@ func encodeMarshalYAMLValue(value any) (any, error) {
func (o *Map[K, V]) ToYamlNode(n NodeBuilder, l any) *yaml.Node {
p := utils.CreateEmptyMapNode()
if o != nil {
- p.Content = make([]*yaml.Node, 0)
+ p.Content = make([]*yaml.Node, 0, 2*o.Len())
}
var vn *yaml.Node
@@ -112,6 +112,11 @@ func (o *Map[K, V]) ToYamlNode(n NodeBuilder, l any) *yaml.Node {
}
}
+ keyNodes := keyNodeIndex{mapNode: vn}
+ var lowValues *untypedValueIndex
+ var lowFinder findValueUntyped
+ lowResolved := false
+
for pair := First(o); pair != nil; pair = pair.Next() {
var k any = pair.Key()
if m, ok := k.(marshaler); ok { // TODO marshal inline?
@@ -123,21 +128,31 @@ func (o *Map[K, V]) ToYamlNode(n NodeBuilder, l any) *yaml.Node {
ks := k.(string)
var keyStyle yaml.Style
- keyNode := findKeyNode(ks, vn)
+ keyNode := keyNodes.find(ks)
if keyNode != nil {
keyStyle = keyNode.Style
}
- var lv any
- if l != nil {
+ // resolve the low-level map once, indexing it so each key is found without a scan.
+ if !lowResolved {
+ lowResolved = true
if hvut, ok := l.(hasValueUntyped); ok {
vut := hvut.GetValueUntyped()
- if m, ok := vut.(findValueUntyped); ok {
- lv = m.FindValueUntyped(ks)
+ if indexer, ok := vut.(untypedValueIndexer); ok {
+ lowValues = indexer.untypedValueIndex()
+ } else if m, ok := vut.(findValueUntyped); ok {
+ lowFinder = m
}
}
}
+ var lv any
+ if lowValues != nil {
+ lv = lowValues.find(ks)
+ } else if lowFinder != nil {
+ lv = lowFinder.FindValueUntyped(ks)
+ }
+
n.AddYAMLNode(p, &nodes.NodeEntry{
Tag: ks,
Key: ks,
@@ -152,6 +167,114 @@ func (o *Map[K, V]) ToYamlNode(n NodeBuilder, l any) *yaml.Node {
return p
}
+// indexScanLimit is the entry count up to which an index answers lookups by scanning; above it, a hash
+// map is built on first use.
+const indexScanLimit = 16
+
+// keyNodeIndex finds key nodes of a mapping node with the same first-match semantics as findKeyNode.
+type keyNodeIndex struct {
+ mapNode *yaml.Node
+ byKey map[string]*yaml.Node
+}
+
+func (x *keyNodeIndex) find(key string) *yaml.Node {
+ if x.mapNode == nil || len(x.mapNode.Content) <= 2*indexScanLimit {
+ return findKeyNode(key, x.mapNode)
+ }
+ if x.byKey == nil {
+ x.byKey = make(map[string]*yaml.Node, len(x.mapNode.Content)/2)
+ for i := 0; i < len(x.mapNode.Content); i += 2 {
+ if _, seen := x.byKey[x.mapNode.Content[i].Value]; !seen {
+ x.byKey[x.mapNode.Content[i].Value] = x.mapNode.Content[i]
+ }
+ }
+ }
+ return x.byKey[key]
+}
+
+// untypedValueIndexer is implemented by Map. It lets ToYamlNode resolve the low-level value of every key
+// in one pass over the low-level map, rather than one FindValueUntyped scan per key.
+type untypedValueIndexer interface {
+ untypedValueIndex() *untypedValueIndex
+}
+
+// untypedValueIndex answers FindValueUntyped lookups with identical results: a pair matches a key when
+// the string form of its untyped key value, or of the key itself, equals the key, and the oldest
+// matching pair wins.
+type untypedValueIndex struct {
+ names []string // match strings, oldest pair first
+ values []any // values[i] is the value of the pair names[i] came from
+ byName map[string]int
+
+ // braceForms is set when the keys are structs printed as "{...}". Those forms are not indexed, so a
+ // key starting with a brace is answered by the exact scan instead.
+ braceForms bool
+ finder findValueUntyped
+}
+
+func (o *Map[K, V]) untypedValueIndex() *untypedValueIndex {
+ x := &untypedValueIndex{finder: o, braceForms: formatsAsStructLiteral(reflect.TypeFor[K]())}
+ if o == nil {
+ return x
+ }
+ for pair := o.Oldest(); pair != nil; pair = pair.Next() {
+ var k any = pair.Key
+ value := any(pair.Value)
+ if hvut, ok := k.(hasValueUntyped); ok {
+ x.names = append(x.names, formatUntyped(hvut.GetValueUntyped()))
+ x.values = append(x.values, value)
+ }
+ if !x.braceForms {
+ x.names = append(x.names, formatUntyped(k))
+ x.values = append(x.values, value)
+ }
+ }
+ return x
+}
+
+func (x *untypedValueIndex) find(key string) any {
+ if x.braceForms && strings.HasPrefix(key, "{") {
+ return x.finder.FindValueUntyped(key)
+ }
+ if len(x.names) <= indexScanLimit {
+ for i, name := range x.names {
+ if name == key {
+ return x.values[i]
+ }
+ }
+ return nil
+ }
+ if x.byName == nil {
+ x.byName = make(map[string]int, len(x.names))
+ for i, name := range x.names {
+ if _, seen := x.byName[name]; !seen {
+ x.byName[name] = i
+ }
+ }
+ }
+ if i, ok := x.byName[key]; ok {
+ return x.values[i]
+ }
+ return nil
+}
+
+// formatUntyped returns fmt.Sprintf("%v", v), skipping the formatter for plain strings.
+func formatUntyped(v any) string {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ return fmt.Sprintf("%v", v)
+}
+
+// formatsAsStructLiteral reports whether %v prints values of t as "{...}": a struct with no method that
+// would take over its formatting.
+func formatsAsStructLiteral(t reflect.Type) bool {
+ return t.Kind() == reflect.Struct &&
+ !t.Implements(reflect.TypeFor[fmt.Formatter]()) &&
+ !t.Implements(reflect.TypeFor[fmt.Stringer]()) &&
+ !t.Implements(reflect.TypeFor[error]())
+}
+
func findKeyNode(key string, m *yaml.Node) *yaml.Node {
if m == nil {
return nil
diff --git a/orderedmap/builder_index_test.go b/orderedmap/builder_index_test.go
new file mode 100644
index 00000000..1b232f2c
--- /dev/null
+++ b/orderedmap/builder_index_test.go
@@ -0,0 +1,191 @@
+package orderedmap
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "github.com/pb33f/libopenapi/datamodel/high/nodes"
+ "github.com/pb33f/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+// untypedKey mirrors low.KeyReference: a struct key exposing its string through GetValueUntyped.
+type untypedKey struct {
+ Value string
+ Node *yaml.Node
+}
+
+func (k untypedKey) GetValueUntyped() any { return k.Value }
+
+// stringerKey is a struct whose String method takes over %v formatting.
+type stringerKey struct{ Value string }
+
+func (k stringerKey) String() string { return "s:" + k.Value }
+
+// plainKey is a struct with no methods at all, printed as "{...}" by %v.
+type plainKey struct{ Value string }
+
+// errorKey and formatterKey take over %v formatting through error and fmt.Formatter.
+type errorKey struct{ Value string }
+
+func (k errorKey) Error() string { return k.Value }
+
+type formatterKey struct{ Value string }
+
+func (k formatterKey) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(k.Value)) }
+
+// requireIndexMatchesScan asserts the index answers every probe exactly as the FindValueUntyped scan does.
+func requireIndexMatchesScan[K comparable, V any](t *testing.T, m *Map[K, V], probes []string) {
+ t.Helper()
+ x := m.untypedValueIndex()
+ for _, probe := range probes {
+ require.Equal(t, m.FindValueUntyped(probe), x.find(probe), "probe %q", probe)
+ }
+}
+
+func indexProbes(n int) []string {
+ probes := []string{"", "missing", "{", "{key-0", "s:key-1"}
+ for i := 0; i < n; i++ {
+ probes = append(probes, "key-"+strconv.Itoa(i))
+ }
+ return probes
+}
+
+func TestUntypedValueIndex_MatchesScan(t *testing.T) {
+ for _, n := range []int{0, 1, 5, indexScanLimit, indexScanLimit + 1, 64} {
+ t.Run(strconv.Itoa(n), func(t *testing.T) {
+ probes := indexProbes(n)
+
+ strings := New[string, int]()
+ structs := New[untypedKey, int]()
+ pointers := New[*untypedKey, int]()
+ stringers := New[stringerKey, int]()
+ plains := New[plainKey, int]()
+ for i := 0; i < n; i++ {
+ name := "key-" + strconv.Itoa(i)
+ strings.Set(name, i)
+ structs.Set(untypedKey{Value: name, Node: &yaml.Node{}}, i)
+ pointers.Set(&untypedKey{Value: name}, i)
+ stringers.Set(stringerKey{Value: name}, i)
+ plains.Set(plainKey{Value: name}, i)
+ }
+ // the %v forms of these keys are only knowable at runtime, so probe for them too.
+ for k := range structs.KeysFromOldest() {
+ probes = append(probes, fmt.Sprintf("%v", k))
+ }
+ for k := range pointers.KeysFromOldest() {
+ probes = append(probes, fmt.Sprintf("%v", k))
+ }
+ for k := range plains.KeysFromOldest() {
+ probes = append(probes, fmt.Sprintf("%v", k))
+ }
+
+ requireIndexMatchesScan(t, strings, probes)
+ requireIndexMatchesScan(t, structs, probes)
+ requireIndexMatchesScan(t, pointers, probes)
+ requireIndexMatchesScan(t, stringers, probes)
+ requireIndexMatchesScan(t, plains, probes)
+ })
+ }
+}
+
+// When two pairs produce the same match string, the oldest pair wins, as it does for the scan.
+func TestUntypedValueIndex_OldestMatchWins(t *testing.T) {
+ for _, n := range []int{2, indexScanLimit + 4} {
+ m := New[untypedKey, int]()
+ for i := 0; i < n; i++ {
+ m.Set(untypedKey{Value: "same", Node: &yaml.Node{Line: i}}, i)
+ }
+ require.Equal(t, 0, m.untypedValueIndex().find("same"))
+ requireIndexMatchesScan(t, m, []string{"same", "other"})
+ }
+}
+
+// A nil map finds nothing, matching FindValueUntyped on a nil map.
+func TestUntypedValueIndex_NilMap(t *testing.T) {
+ var m *Map[untypedKey, int]
+ x := m.untypedValueIndex()
+ require.Nil(t, x.find("anything"))
+ require.Nil(t, m.FindValueUntyped("anything"))
+}
+
+func TestFormatsAsStructLiteral(t *testing.T) {
+ require.True(t, formatsAsStructLiteral(reflect.TypeFor[untypedKey]()))
+ require.True(t, formatsAsStructLiteral(reflect.TypeFor[plainKey]()))
+ require.False(t, formatsAsStructLiteral(reflect.TypeFor[*untypedKey]()))
+ require.False(t, formatsAsStructLiteral(reflect.TypeFor[string]()))
+ require.False(t, formatsAsStructLiteral(reflect.TypeFor[stringerKey]()))
+ require.False(t, formatsAsStructLiteral(reflect.TypeFor[errorKey]()))
+ require.False(t, formatsAsStructLiteral(reflect.TypeFor[formatterKey]()))
+}
+
+func TestKeyNodeIndex_MatchesFindKeyNode(t *testing.T) {
+ require.Nil(t, (&keyNodeIndex{}).find("anything"))
+
+ for _, n := range []int{1, indexScanLimit, indexScanLimit + 1, 64} {
+ mapNode := &yaml.Node{Kind: yaml.MappingNode}
+ for i := 0; i < n; i++ {
+ // every key appears twice, so first-match semantics are exercised.
+ for _, dup := range []int{0, 1} {
+ mapNode.Content = append(mapNode.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Value: "key-" + strconv.Itoa(i), Line: dup},
+ &yaml.Node{Kind: yaml.ScalarNode, Value: "value"})
+ }
+ }
+ x := &keyNodeIndex{mapNode: mapNode}
+ for _, probe := range append(indexProbes(n), "value") {
+ require.Same(t, findKeyNode(probe, mapNode), x.find(probe), "probe %q", probe)
+ }
+ }
+}
+
+// scanOnlyLookup implements FindValueUntyped without the index hook, as a type outside this package would.
+type scanOnlyLookup struct{ values map[string]any }
+
+func (s scanOnlyLookup) FindValueUntyped(key string) any { return s.values[key] }
+
+type untypedHolder struct {
+ value any
+ node *yaml.Node
+}
+
+func (h untypedHolder) GetValueUntyped() any { return h.value }
+func (h untypedHolder) GetValueNode() *yaml.Node { return h.node }
+
+type recordingBuilder struct{ lowValues map[string]any }
+
+func (r *recordingBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *yaml.Node {
+ r.lowValues[entry.Key] = entry.LowValue
+ return parent
+}
+
+// ToYamlNode resolves low values through the index when the low map provides one, and through
+// FindValueUntyped when it does not.
+func TestToYamlNode_LowValueResolution(t *testing.T) {
+ high := New[string, string]()
+ for i := 0; i < indexScanLimit+2; i++ {
+ high.Set("key-"+strconv.Itoa(i), "v")
+ }
+
+ lowMap := New[untypedKey, int]()
+ for i := 0; i < indexScanLimit+2; i++ {
+ lowMap.Set(untypedKey{Value: "key-" + strconv.Itoa(i)}, i)
+ }
+
+ indexed := &recordingBuilder{lowValues: map[string]any{}}
+ high.ToYamlNode(indexed, untypedHolder{value: lowMap})
+ scanned := &recordingBuilder{lowValues: map[string]any{}}
+ high.ToYamlNode(scanned, untypedHolder{value: scanOnlyLookup{values: map[string]any{"key-3": 3}}})
+ nilLow := &recordingBuilder{lowValues: map[string]any{}}
+ high.ToYamlNode(nilLow, untypedHolder{value: (*Map[untypedKey, int])(nil)})
+
+ for i := 0; i < indexScanLimit+2; i++ {
+ key := "key-" + strconv.Itoa(i)
+ require.Equal(t, i, indexed.lowValues[key])
+ require.Nil(t, nilLow.lowValues[key])
+ }
+ require.Equal(t, 3, scanned.lowValues["key-3"])
+ require.Nil(t, scanned.lowValues["key-4"])
+}
diff --git a/overlay/engine.go b/overlay/engine.go
index c58ac5b4..f793f2e7 100644
--- a/overlay/engine.go
+++ b/overlay/engine.go
@@ -7,6 +7,7 @@ import (
"github.com/pb33f/jsonpath/pkg/jsonpath"
"github.com/pb33f/jsonpath/pkg/jsonpath/config"
highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay"
+ "github.com/pb33f/libopenapi/internal/jsonnode"
"github.com/pb33f/libopenapi/utils"
"go.yaml.in/yaml/v4"
)
@@ -23,7 +24,7 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) {
}
var rootNode yaml.Node
- if err := yaml.Unmarshal(targetBytes, &rootNode); err != nil {
+ if err := jsonnode.Unmarshal(targetBytes, &rootNode); err != nil {
return nil, err
}
diff --git a/pipeline_bench_test.go b/pipeline_bench_test.go
new file mode 100644
index 00000000..883c88a4
--- /dev/null
+++ b/pipeline_bench_test.go
@@ -0,0 +1,208 @@
+// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
+// SPDX-License-Identifier: MIT
+
+package libopenapi_test
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "os"
+ "runtime"
+ "testing"
+
+ "github.com/pb33f/libopenapi"
+ "github.com/pb33f/libopenapi/bundler"
+ "github.com/pb33f/libopenapi/datamodel"
+ "github.com/pb33f/libopenapi/datamodel/high/base"
+)
+
+// Pipeline benchmarks exercise the full public entry points against large, real-world specifications.
+// Each benchmark also reports "retained-B/op": the live heap left behind by one iteration's result after a
+// forced GC, which is the memory a long-lived consumer pays to hold the model.
+
+func pipelineSpec(b *testing.B, path string) []byte {
+ b.Helper()
+ spec, err := os.ReadFile(path)
+ if err != nil {
+ b.Fatalf("read %s: %v", path, err)
+ }
+ return spec
+}
+
+func pipelineConfig() *datamodel.DocumentConfiguration {
+ return &datamodel.DocumentConfiguration{
+ IgnorePolymorphicCircularReferences: true,
+ IgnoreArrayCircularReferences: true,
+ // keep resolution errors out of the benchmark output, where they break benchstat parsing.
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ }
+}
+
+func heapInUse() uint64 {
+ runtime.GC()
+ runtime.GC()
+ var ms runtime.MemStats
+ runtime.ReadMemStats(&ms)
+ return ms.HeapAlloc
+}
+
+func benchmarkPipelineV3(b *testing.B, path string) {
+ spec := pipelineSpec(b, path)
+ b.ReportAllocs()
+ b.ResetTimer()
+ var keep any
+ for i := 0; i < b.N; i++ {
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ m, _ := doc.BuildV3Model()
+ if m == nil {
+ b.Fatal("nil model")
+ }
+ keep = m
+ }
+ b.StopTimer()
+ keep = nil
+ before := heapInUse()
+ doc, _ := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ m, _ := doc.BuildV3Model()
+ keep = m
+ after := heapInUse()
+ runtime.KeepAlive(keep)
+ runtime.KeepAlive(doc)
+ b.ReportMetric(float64(after-before), "retained-B/op")
+}
+
+func benchmarkPipelineV2(b *testing.B, path string) {
+ spec := pipelineSpec(b, path)
+ b.ReportAllocs()
+ b.ResetTimer()
+ var keep any
+ for i := 0; i < b.N; i++ {
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ m, _ := doc.BuildV2Model()
+ if m == nil {
+ b.Fatal("nil model")
+ }
+ keep = m
+ }
+ b.StopTimer()
+ keep = nil
+ before := heapInUse()
+ doc, _ := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ m, _ := doc.BuildV2Model()
+ keep = m
+ after := heapInUse()
+ runtime.KeepAlive(keep)
+ runtime.KeepAlive(doc)
+ b.ReportMetric(float64(after-before), "retained-B/op")
+}
+
+func BenchmarkPipeline_BuildV3_Stripe(b *testing.B) { benchmarkPipelineV3(b, "test_specs/stripe.yaml") }
+func BenchmarkPipeline_BuildV3_DocuSign(b *testing.B) {
+ benchmarkPipelineV3(b, "test_specs/docusignv3.1.json")
+}
+func BenchmarkPipeline_BuildV3_Asana(b *testing.B) { benchmarkPipelineV3(b, "test_specs/asana.yaml") }
+func BenchmarkPipeline_BuildV2_K8s(b *testing.B) { benchmarkPipelineV2(b, "test_specs/k8s.json") }
+func BenchmarkPipeline_BuildV2_Xsoar(b *testing.B) { benchmarkPipelineV2(b, "test_specs/xsoar.json") }
+
+func BenchmarkPipeline_Render_Stripe(b *testing.B) {
+ spec := pipelineSpec(b, "test_specs/stripe.yaml")
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ if m, _ := doc.BuildV3Model(); m == nil {
+ b.Fatal("nil model")
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ out, rErr := doc.Render()
+ if rErr != nil || len(out) == 0 {
+ b.Fatal(rErr)
+ }
+ }
+}
+
+func BenchmarkPipeline_Render_DocuSignJSON(b *testing.B) {
+ spec := pipelineSpec(b, "test_specs/docusignv3.1.json")
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ if m, _ := doc.BuildV3Model(); m == nil {
+ b.Fatal("nil model")
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ out, rErr := doc.Render()
+ if rErr != nil || len(out) == 0 {
+ b.Fatal(rErr)
+ }
+ }
+}
+
+func BenchmarkPipeline_Compare_Stripe(b *testing.B) {
+ left := pipelineSpec(b, "test_specs/stripe.yaml")
+ // mutate a slice of the spec so the comparison has real changes to report, not just an identity walk.
+ right := bytes.Replace(left, []byte("type: string"), []byte("type: integer"), 200)
+ right = bytes.Replace(right, []byte("nullable: true"), []byte("nullable: false"), 300)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ l, err := libopenapi.NewDocumentWithConfiguration(left, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ r, err := libopenapi.NewDocumentWithConfiguration(right, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ changes, _ := libopenapi.CompareDocuments(l, r)
+ if changes == nil {
+ b.Fatal("no changes")
+ }
+ }
+}
+
+func BenchmarkPipeline_Bundle_Stripe(b *testing.B) {
+ spec := pipelineSpec(b, "test_specs/stripe.yaml")
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ out, _ := bundler.BundleBytes(spec, pipelineConfig())
+ if len(out) == 0 {
+ b.Fatal("empty bundle")
+ }
+ }
+}
+
+// BenchmarkPipeline_RenderSchemasInline_Stripe renders every component schema inline in validation mode,
+// the way request/response validators compile schemas.
+func BenchmarkPipeline_RenderSchemasInline_Stripe(b *testing.B) {
+ spec := pipelineSpec(b, "test_specs/stripe.yaml")
+ doc, err := libopenapi.NewDocumentWithConfiguration(spec, pipelineConfig())
+ if err != nil {
+ b.Fatal(err)
+ }
+ m, _ := doc.BuildV3Model()
+ if m == nil {
+ b.Fatal("nil model")
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ for _, proxy := range m.Model.Components.Schemas.FromOldest() {
+ if _, rErr := proxy.Schema().RenderInlineWithContext(base.NewInlineRenderContextForValidation()); rErr != nil {
+ b.Fatal(rErr)
+ }
+ }
+ }
+}