diff --git a/cache/cache.go b/cache/cache.go index b8e1eee..ef09fdb 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,4 +1,4 @@ -// Copyright 2025-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package cache @@ -21,6 +21,17 @@ type SchemaCacheEntry struct { ResourceNodes map[string]*yaml.Node } +// SchemaResourceCacheEntry holds one rendered document-level JSON Schema resource. +// Resource entries are shared by many compiled schema entry points from the same parsed document. +// RenderedNode may point at source YAML for generic validation; consumers must treat it as read-only. +type SchemaResourceCacheEntry struct { + RenderedInline []byte + ReferenceSchema string + RenderedJSON []byte + RenderedNode *yaml.Node + SourceRootNode *yaml.Node // Keeps pointer-identity cache keys live for the cache entry lifetime. +} + // SchemaCache defines the interface for schema caching implementations. // The key is a uint64 hash of the schema (from schema.GoLow().Hash()). type SchemaCache interface { @@ -28,3 +39,12 @@ type SchemaCache interface { Store(key uint64, value *SchemaCacheEntry) Range(f func(key uint64, value *SchemaCacheEntry) bool) } + +// SchemaResourceCache caches rendered document resources by parsed document identity. +// Entries are immutable once stored; implementations must be safe for concurrent use. +type SchemaResourceCache interface { + Load(key string) (*SchemaResourceCacheEntry, bool) + Store(key string, value *SchemaResourceCacheEntry) + Range(f func(key string, value *SchemaResourceCacheEntry) bool) + Release() +} diff --git a/cache/cache_test.go b/cache/cache_test.go index ea3f678..821182f 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -1,9 +1,10 @@ -// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package cache import ( + "sync" "testing" "github.com/pb33f/libopenapi/datamodel/high/base" @@ -76,6 +77,30 @@ func TestDefaultCache_StoreNilCache(t *testing.T) { assert.Nil(t, cache) } +func TestDefaultCache_Release(t *testing.T) { + cache := NewDefaultCache() + cache.Store(1, &SchemaCacheEntry{RenderedInline: []byte("one")}) + cache.Store(2, &SchemaCacheEntry{RenderedInline: []byte("two")}) + + cache.Release() + + loaded, ok := cache.Load(1) + assert.False(t, ok) + assert.Nil(t, loaded) + + count := 0 + cache.Range(func(key uint64, value *SchemaCacheEntry) bool { + count++ + return true + }) + assert.Equal(t, 0, count) + + cache.Release() + + var nilCache *DefaultCache + nilCache.Release() +} + func TestDefaultCache_Range(t *testing.T) { cache := NewDefaultCache() @@ -205,6 +230,142 @@ func TestDefaultCache_MultipleKeys(t *testing.T) { assert.Equal(t, []byte("value3"), val3.RenderedInline) } +func TestNewDefaultSchemaResourceCache(t *testing.T) { + cache := NewDefaultSchemaResourceCache() + + assert.NotNil(t, cache) + assert.NotNil(t, cache.m) +} + +func TestDefaultSchemaResourceCache_StoreLoadRangeAndOverwrite(t *testing.T) { + cache := NewDefaultSchemaResourceCache() + first := &SchemaResourceCacheEntry{ + RenderedInline: []byte("first"), + ReferenceSchema: "first", + RenderedJSON: []byte(`{"type":"object"}`), + } + second := &SchemaResourceCacheEntry{ + RenderedInline: []byte("second"), + RenderedJSON: []byte(`{"type":"string"}`), + } + + cache.Store("resource", first) + loaded, ok := cache.Load("resource") + require.True(t, ok) + assert.Equal(t, first.RenderedInline, loaded.RenderedInline) + assert.Equal(t, first.ReferenceSchema, loaded.ReferenceSchema) + + cache.Store("resource", second) + loaded, ok = cache.Load("resource") + require.True(t, ok) + assert.Equal(t, second.RenderedInline, loaded.RenderedInline) + + seen := 0 + cache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + seen++ + assert.Equal(t, "resource", key) + assert.Equal(t, second.RenderedJSON, value.RenderedJSON) + return false + }) + assert.Equal(t, 1, seen) +} + +func TestDefaultSchemaResourceCache_Release(t *testing.T) { + cache := NewDefaultSchemaResourceCache() + cache.Store("one", &SchemaResourceCacheEntry{RenderedInline: []byte("one")}) + cache.Store("two", &SchemaResourceCacheEntry{RenderedInline: []byte("two")}) + + cache.Release() + + loaded, ok := cache.Load("one") + assert.False(t, ok) + assert.Nil(t, loaded) + + count := 0 + cache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + count++ + return true + }) + assert.Equal(t, 0, count) + + cache.Release() + + var nilCache *DefaultSchemaResourceCache + nilCache.Release() +} + +func TestDefaultSchemaResourceCache_EdgeCases(t *testing.T) { + cache := NewDefaultSchemaResourceCache() + loaded, ok := cache.Load("missing") + assert.False(t, ok) + assert.Nil(t, loaded) + + var nilCache *DefaultSchemaResourceCache + loaded, ok = nilCache.Load("missing") + assert.False(t, ok) + assert.Nil(t, loaded) + nilCache.Store("resource", &SchemaResourceCacheEntry{}) + + called := false + nilCache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + called = true + return true + }) + assert.False(t, called) + + count := 0 + cache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + count++ + return true + }) + assert.Equal(t, 0, count) + + cache.m.Store(42, &SchemaResourceCacheEntry{}) + cache.m.Store("invalid", "not-a-resource-entry") + cache.Store("valid", &SchemaResourceCacheEntry{RenderedInline: []byte("ok")}) + var keys []string + cache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + keys = append(keys, key) + return true + }) + assert.Equal(t, []string{"valid"}, keys) +} + +func TestDefaultSchemaResourceCache_ThreadSafety(t *testing.T) { + cache := NewDefaultSchemaResourceCache() + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(val int) { + defer wg.Done() + cache.Store(string(rune('a'+val)), &SchemaResourceCacheEntry{ + RenderedInline: []byte{byte(val)}, + RenderedJSON: []byte{byte(val)}, + }) + }(i) + } + wg.Wait() + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(val int) { + defer wg.Done() + loaded, ok := cache.Load(string(rune('a' + val))) + assert.True(t, ok) + assert.NotNil(t, loaded) + }(i) + } + wg.Wait() + + count := 0 + cache.Range(func(key string, value *SchemaResourceCacheEntry) bool { + count++ + return true + }) + assert.Equal(t, 20, count) +} + func TestDefaultCache_ThreadSafety(t *testing.T) { cache := NewDefaultCache() diff --git a/cache/default_cache.go b/cache/default_cache.go index c27211c..6b6b45b 100644 --- a/cache/default_cache.go +++ b/cache/default_cache.go @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package cache import "sync" @@ -7,13 +10,42 @@ type DefaultCache struct { m *sync.Map } -var _ SchemaCache = &DefaultCache{} +// DefaultSchemaResourceCache is the default thread-safe cache for rendered document resources. +type DefaultSchemaResourceCache struct { + m *sync.Map +} + +var ( + _ SchemaCache = &DefaultCache{} + _ SchemaResourceCache = &DefaultSchemaResourceCache{} +) // NewDefaultCache creates a new DefaultCache with an initialized sync.Map. func NewDefaultCache() *DefaultCache { return &DefaultCache{m: &sync.Map{}} } +// NewDefaultSchemaResourceCache creates a default cache for rendered document resources. +func NewDefaultSchemaResourceCache() *DefaultSchemaResourceCache { + return &DefaultSchemaResourceCache{m: &sync.Map{}} +} + +// Release clears all cached schema entries. +func (c *DefaultCache) Release() { + if c == nil || c.m == nil { + return + } + c.m.Clear() +} + +// Release clears all cached rendered document resources. +func (c *DefaultSchemaResourceCache) Release() { + if c == nil || c.m == nil { + return + } + c.m.Clear() +} + // Load retrieves a schema from the cache. func (c *DefaultCache) Load(key uint64) (*SchemaCacheEntry, bool) { if c == nil || c.m == nil { @@ -52,3 +84,42 @@ func (c *DefaultCache) Range(f func(key uint64, value *SchemaCacheEntry) bool) { return f(key, val) }) } + +// Load retrieves a rendered document resource from the cache. +func (c *DefaultSchemaResourceCache) Load(key string) (*SchemaResourceCacheEntry, bool) { + if c == nil || c.m == nil { + return nil, false + } + val, ok := c.m.Load(key) + if !ok { + return nil, false + } + resourceCache, ok := val.(*SchemaResourceCacheEntry) + return resourceCache, ok +} + +// Store saves a rendered document resource to the cache. +func (c *DefaultSchemaResourceCache) Store(key string, value *SchemaResourceCacheEntry) { + if c == nil || c.m == nil { + return + } + c.m.Store(key, value) +} + +// Range calls f for each rendered document resource cache entry. +func (c *DefaultSchemaResourceCache) Range(f func(key string, value *SchemaResourceCacheEntry) bool) { + if c == nil || c.m == nil { + return + } + c.m.Range(func(k, v interface{}) bool { + key, ok := k.(string) + if !ok { + return true + } + val, ok := v.(*SchemaResourceCacheEntry) + if !ok { + return true + } + return f(key, val) + }) +} diff --git a/config/config.go b/config/config.go index 31e89de..c38657e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package config import ( @@ -46,12 +49,13 @@ type ValidationOptions struct { OpenAPIMode bool // Enable OpenAPI-specific vocabulary validation AllowScalarCoercion bool // Enable string->boolean/number coercion Formats map[string]func(v any) error - SchemaCache cache.SchemaCache // Optional cache for compiled schemas - PathTree radix.PathLookup // O(k) path lookup via radix tree (built automatically) - pathTreeDisabled bool // Internal: true if radix tree auto-build was disabled via DisablePathTree - Logger *slog.Logger // Logger for debug/error output (nil = silent) - AllowXMLBodyValidation bool // Allows to convert XML to JSON for validating a request/response body. - AllowURLEncodedBodyValidation bool // Allows to convert URL Encoded to JSON for validating a request/response body. + SchemaCache cache.SchemaCache // Optional cache for compiled schemas + SchemaResourceCache cache.SchemaResourceCache // Optional cache for rendered document-level schema resources + PathTree radix.PathLookup // O(k) path lookup via radix tree (built automatically) + pathTreeDisabled bool // Internal: true if radix tree auto-build was disabled via DisablePathTree + Logger *slog.Logger // Logger for debug/error output (nil = silent) + AllowXMLBodyValidation bool // Allows to convert XML to JSON for validating a request/response body. + AllowURLEncodedBodyValidation bool // Allows to convert URL Encoded to JSON for validating a request/response body. // strict mode options - detect undeclared properties even when additionalProperties: true StrictMode bool // Enable strict property validation @@ -69,11 +73,12 @@ type Option func(*ValidationOptions) func NewValidationOptions(opts ...Option) *ValidationOptions { // create the set of default values o := &ValidationOptions{ - FormatAssertions: false, - ContentAssertions: false, - SecurityValidation: true, - OpenAPIMode: true, // Enable OpenAPI vocabulary by default - SchemaCache: cache.NewDefaultCache(), // Enable caching by default + FormatAssertions: false, + ContentAssertions: false, + SecurityValidation: true, + OpenAPIMode: true, // Enable OpenAPI vocabulary by default + SchemaCache: cache.NewDefaultCache(), // Enable compiled schema caching by default + SchemaResourceCache: cache.NewDefaultSchemaResourceCache(), // Enable rendered resource caching by default } for _, opt := range opts { @@ -84,6 +89,38 @@ func NewValidationOptions(opts ...Option) *ValidationOptions { return o } +// Release clears cached validation state and drops references that can keep +// parsed documents, rendered schemas, path trees, or user-provided callbacks alive. +func (o *ValidationOptions) Release() { + if o == nil { + return + } + releaseIfSupported(o.SchemaCache) + releaseIfSupported(o.SchemaResourceCache) + releaseIfSupported(o.PathTree) + + o.RegexEngine = nil + o.RegexCache = nil + o.AuthenticationFunc = nil + o.Formats = nil + o.SchemaCache = nil + o.SchemaResourceCache = nil + o.PathTree = nil + o.Logger = nil + o.StrictIgnorePaths = nil + o.StrictIgnoredHeaders = nil +} + +type releaser interface { + Release() +} + +func releaseIfSupported(value any) { + if r, ok := value.(releaser); ok { + r.Release() + } +} + // WithExistingOpts returns an Option that will copy the values from the supplied ValidationOptions instance func WithExistingOpts(options *ValidationOptions) Option { return func(o *ValidationOptions) { @@ -98,6 +135,7 @@ func WithExistingOpts(options *ValidationOptions) Option { o.AllowScalarCoercion = options.AllowScalarCoercion o.Formats = options.Formats o.SchemaCache = options.SchemaCache + o.SchemaResourceCache = options.SchemaResourceCache o.PathTree = options.PathTree o.pathTreeDisabled = options.pathTreeDisabled o.Logger = options.Logger @@ -224,6 +262,15 @@ func WithSchemaCache(schemaCache cache.SchemaCache) Option { } } +// WithSchemaResourceCache sets a cache for rendered document-level schema resources. +// Pass nil to disable resource reuse when compiling referenced schemas. +// Cached entries retain source YAML nodes, so long-lived shared caches should be bounded or scoped deliberately. +func WithSchemaResourceCache(schemaResourceCache cache.SchemaResourceCache) Option { + return func(o *ValidationOptions) { + o.SchemaResourceCache = schemaResourceCache + } +} + // WithPathTree sets a custom radix tree for path matching. // The default is built automatically from the OpenAPI specification. func WithPathTree(pathTree radix.PathLookup) Option { diff --git a/config/config_test.go b/config/config_test.go index 7177620..9d0096b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package config @@ -9,6 +9,9 @@ import ( "sync" "testing" + validatorcache "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/radix" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pb33f/testify/assert" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -26,6 +29,8 @@ func TestNewValidationOptions_Defaults(t *testing.T) { assert.False(t, opts.AllowURLEncodedBodyValidation) // Default is false assert.Nil(t, opts.RegexEngine) assert.Nil(t, opts.RegexCache) + assert.NotNil(t, opts.SchemaCache) + assert.NotNil(t, opts.SchemaResourceCache) } func TestNewValidationOptions_WithNilOption(t *testing.T) { @@ -40,6 +45,7 @@ func TestNewValidationOptions_WithNilOption(t *testing.T) { assert.False(t, opts.AllowXMLBodyValidation) // Default is false assert.Nil(t, opts.RegexEngine) assert.Nil(t, opts.RegexCache) + assert.NotNil(t, opts.SchemaResourceCache) } func TestWithFormatAssertions(t *testing.T) { @@ -117,9 +123,13 @@ func TestWithRegexEngine(t *testing.T) { func TestWithExistingOpts(t *testing.T) { // Create original options with all settings enabled var testEngine jsonschema.RegexpEngine = nil + schemaCache := validatorcache.NewDefaultCache() + schemaResourceCache := validatorcache.NewDefaultSchemaResourceCache() original := &ValidationOptions{ RegexEngine: testEngine, RegexCache: &sync.Map{}, + SchemaCache: schemaCache, + SchemaResourceCache: schemaResourceCache, FormatAssertions: true, AllowXMLBodyValidation: true, AllowURLEncodedBodyValidation: true, @@ -137,6 +147,55 @@ func TestWithExistingOpts(t *testing.T) { assert.Equal(t, original.FormatAssertions, opts.FormatAssertions) assert.Equal(t, original.ContentAssertions, opts.ContentAssertions) assert.Equal(t, original.SecurityValidation, opts.SecurityValidation) + assert.Same(t, schemaCache, opts.SchemaCache) + assert.Same(t, schemaResourceCache, opts.SchemaResourceCache) +} + +func TestValidationOptions_Release(t *testing.T) { + schemaCache := validatorcache.NewDefaultCache() + schemaCache.Store(1, &validatorcache.SchemaCacheEntry{RenderedInline: []byte("schema")}) + + schemaResourceCache := validatorcache.NewDefaultSchemaResourceCache() + schemaResourceCache.Store("resource", &validatorcache.SchemaResourceCacheEntry{RenderedInline: []byte("resource")}) + + pathTree := radix.NewPathTree() + pathTree.Insert("/pets", &v3.PathItem{}) + + opts := NewValidationOptions( + WithSchemaCache(schemaCache), + WithSchemaResourceCache(schemaResourceCache), + WithPathTree(pathTree), + WithRegexCache(&sync.Map{}), + WithCustomFormat("custom", func(v any) error { return nil }), + WithStrictIgnorePaths("$.body.internal"), + WithStrictIgnoredHeaders("X-Internal"), + WithLogger(slog.Default()), + ) + + opts.Release() + + assert.Nil(t, opts.RegexEngine) + assert.Nil(t, opts.RegexCache) + assert.Nil(t, opts.AuthenticationFunc) + assert.Nil(t, opts.Formats) + assert.Nil(t, opts.SchemaCache) + assert.Nil(t, opts.SchemaResourceCache) + assert.Nil(t, opts.PathTree) + assert.Nil(t, opts.Logger) + assert.Nil(t, opts.StrictIgnorePaths) + assert.Nil(t, opts.StrictIgnoredHeaders) + + _, schemaFound := schemaCache.Load(1) + assert.False(t, schemaFound) + + _, resourceFound := schemaResourceCache.Load("resource") + assert.False(t, resourceFound) + assert.Equal(t, 0, pathTree.Size()) + + opts.Release() + + var nilOptions *ValidationOptions + nilOptions.Release() } func TestWithExistingOpts_NilSource(t *testing.T) { @@ -153,6 +212,17 @@ func TestWithExistingOpts_NilSource(t *testing.T) { assert.False(t, opts.AllowXMLBodyValidation) // Default is false assert.Nil(t, opts.RegexEngine) assert.Nil(t, opts.RegexCache) + assert.NotNil(t, opts.SchemaResourceCache) +} + +func TestWithSchemaResourceCache(t *testing.T) { + schemaResourceCache := validatorcache.NewDefaultSchemaResourceCache() + opts := NewValidationOptions(WithSchemaResourceCache(schemaResourceCache)) + + assert.Same(t, schemaResourceCache, opts.SchemaResourceCache) + + opts = NewValidationOptions(WithSchemaResourceCache(nil)) + assert.Nil(t, opts.SchemaResourceCache) } func TestMultipleOptions(t *testing.T) { diff --git a/go.mod b/go.mod index 92157df..15f9ff9 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-openapi/jsonpointer v0.23.1 github.com/goccy/go-yaml v1.19.2 github.com/pb33f/jsonpath v0.8.2 - github.com/pb33f/libopenapi v0.38.2 + github.com/pb33f/libopenapi v0.38.3 github.com/pb33f/testify v0.1.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 go.yaml.in/yaml/v4 v4.0.0-rc.6 diff --git a/go.sum b/go.sum index ffd50b9..8668fd4 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y= github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= -github.com/pb33f/libopenapi v0.38.2 h1:8GyNJESGw8Gj3Di5pRPRpT05wrFSos0Slz6wwendFqA= -github.com/pb33f/libopenapi v0.38.2/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY= +github.com/pb33f/libopenapi v0.38.3 h1:ToJU49mGkr6IVTPvTY9V0QkzuoS0+HarSOuzHN8z54A= +github.com/pb33f/libopenapi v0.38.3/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8= diff --git a/parameters/parameters.go b/parameters/parameters.go index 2fa168a..db57cfa 100644 --- a/parameters/parameters.go +++ b/parameters/parameters.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -64,6 +64,9 @@ type ParameterValidator interface { // ValidateSecurityWithPathItem validates the security requirements for the operation. It returns a boolean stating true // if validation passed (false for failed), and a slice of errors if validation failed. ValidateSecurityWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) + + // Release clears validator-owned options and drops the OpenAPI document reference. + Release() } // NewParameterValidator will create a new ParameterValidator from an OpenAPI 3+ document @@ -77,3 +80,14 @@ type paramValidator struct { options *config.ValidationOptions document *v3.Document } + +func (p *paramValidator) Release() { + if p == nil { + return + } + if p.options != nil { + p.options.Release() + p.options = nil + } + p.document = nil +} diff --git a/parameters/parameters_test.go b/parameters/parameters_test.go new file mode 100644 index 0000000..5a6ffa7 --- /dev/null +++ b/parameters/parameters_test.go @@ -0,0 +1,49 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package parameters + +import ( + "testing" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + + "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/config" +) + +func TestParameterValidator_Release(t *testing.T) { + schemaCache := cache.NewDefaultCache() + schemaCache.Store(1, &cache.SchemaCacheEntry{RenderedInline: []byte("schema")}) + + resourceCache := cache.NewDefaultSchemaResourceCache() + resourceCache.Store("resource", &cache.SchemaResourceCacheEntry{RenderedInline: []byte("resource")}) + + v := NewParameterValidator( + &v3.Document{}, + config.WithSchemaCache(schemaCache), + config.WithSchemaResourceCache(resourceCache), + ) + + validator := v.(*paramValidator) + require.NotNil(t, validator.options) + require.NotNil(t, validator.document) + + v.Release() + + assert.Nil(t, validator.options) + assert.Nil(t, validator.document) + + _, schemaFound := schemaCache.Load(1) + assert.False(t, schemaFound) + + _, resourceFound := resourceCache.Load("resource") + assert.False(t, resourceFound) + + v.Release() + + var nilValidator *paramValidator + nilValidator.Release() +} diff --git a/radix/path_tree.go b/radix/path_tree.go index ae7c634..4fcbbf2 100644 --- a/radix/path_tree.go +++ b/radix/path_tree.go @@ -1,4 +1,4 @@ -// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package radix @@ -42,22 +42,48 @@ func NewPathTree() *PathTree { // Insert adds a path and its PathItem to the tree. // Path should be in OpenAPI format, e.g., "/users/{id}/posts" func (t *PathTree) Insert(path string, pathItem *v3.PathItem) { + if t == nil { + return + } + if t.tree == nil { + t.tree = New[*v3.PathItem]() + } t.tree.Insert(path, pathItem) } // Lookup finds the PathItem for a given request path. // Returns the PathItem, the matched path template, and whether a match was found. func (t *PathTree) Lookup(urlPath string) (*v3.PathItem, string, bool) { + if t == nil || t.tree == nil { + return nil, "", false + } return t.tree.Lookup(urlPath) } // Size returns the number of paths stored in the tree. func (t *PathTree) Size() int { + if t == nil || t.tree == nil { + return 0 + } return t.tree.Size() } +// Release clears all path entries and drops the backing tree. +func (t *PathTree) Release() { + if t == nil { + return + } + if t.tree != nil { + t.tree.Release() + t.tree = nil + } +} + // Walk calls the given function for each path in the tree. func (t *PathTree) Walk(fn func(path string, pathItem *v3.PathItem) bool) { + if t == nil || t.tree == nil { + return + } t.tree.Walk(fn) } diff --git a/radix/path_tree_test.go b/radix/path_tree_test.go index 9f3cc1d..ad36772 100644 --- a/radix/path_tree_test.go +++ b/radix/path_tree_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package radix @@ -54,6 +54,48 @@ paths: assert.NotNil(t, pathItem.Get) } +func TestPathTree_Release(t *testing.T) { + tree := NewPathTree() + tree.Insert("/users/{id}", &v3.PathItem{}) + assert.Equal(t, 1, tree.Size()) + + tree.Release() + + assert.Equal(t, 0, tree.Size()) + pathItem, path, found := tree.Lookup("/users/123") + assert.False(t, found) + assert.Empty(t, path) + assert.Nil(t, pathItem) + + called := false + tree.Walk(func(path string, pathItem *v3.PathItem) bool { + called = true + return true + }) + assert.False(t, called) + + tree.Release() + + var nilTree *PathTree + nilTree.Release() + assert.Equal(t, 0, nilTree.Size()) + _, _, found = nilTree.Lookup("/users/123") + assert.False(t, found) + nilTree.Insert("/ignored", &v3.PathItem{}) +} + +func TestPathTree_InsertReinitializesReleasedTree(t *testing.T) { + tree := NewPathTree() + tree.Release() + + tree.Insert("/users/{id}", &v3.PathItem{}) + + pathItem, path, found := tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "/users/{id}", path) + assert.NotNil(t, pathItem) +} + func TestPathTree_Walk(t *testing.T) { spec := `openapi: 3.1.0 info: diff --git a/radix/tree.go b/radix/tree.go index 8383ad4..1a7c759 100644 --- a/radix/tree.go +++ b/radix/tree.go @@ -1,4 +1,4 @@ -// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT // Package radix provides a radix tree (prefix tree) implementation optimized for @@ -64,6 +64,9 @@ func New[T any]() *Tree[T] { // // Returns true if a new path was inserted, false if an existing path was updated. func (t *Tree[T]) Insert(path string, value T) bool { + if t == nil { + return false + } if t.root == nil { t.root = &node[T]{children: make(map[string]*node[T])} } @@ -116,7 +119,7 @@ func (t *Tree[T]) Insert(path string, value T) bool { // For example, "/users/admin" will match "/users/admin" before "/users/{id}". func (t *Tree[T]) Lookup(urlPath string) (value T, matchedPath string, found bool) { var zero T - if t.root == nil { + if t == nil || t.root == nil { return zero, "", false } @@ -158,20 +161,35 @@ func (t *Tree[T]) lookupRecursive(n *node[T], segments []string, depth int) *lea // Size returns the number of paths stored in the tree. func (t *Tree[T]) Size() int { + if t == nil { + return 0 + } return t.size } // Clear removes all entries from the tree. func (t *Tree[T]) Clear() { + if t == nil { + return + } t.root = &node[T]{children: make(map[string]*node[T])} t.size = 0 } +// Release clears the tree and drops the root node so retained values can be garbage-collected. +func (t *Tree[T]) Release() { + if t == nil { + return + } + t.root = nil + t.size = 0 +} + // Walk calls the given function for each path in the tree. // The function receives the path template and its associated value. // If the function returns false, iteration stops. func (t *Tree[T]) Walk(fn func(path string, value T) bool) { - if t.root == nil { + if t == nil || t.root == nil { return } t.walkRecursive(t.root, fn) diff --git a/radix/tree_test.go b/radix/tree_test.go index 0f6d3df..5f4959e 100644 --- a/radix/tree_test.go +++ b/radix/tree_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package radix @@ -149,6 +149,47 @@ func TestTree_Lookup_NoMatch(t *testing.T) { assert.False(t, found) } +func TestTree_Release(t *testing.T) { + tree := New[string]() + tree.Insert("/users/{id}", "user") + assert.Equal(t, 1, tree.Size()) + + tree.Release() + + assert.Equal(t, 0, tree.Size()) + _, _, found := tree.Lookup("/users/123") + assert.False(t, found) + + called := false + tree.Walk(func(path string, value string) bool { + called = true + return true + }) + assert.False(t, called) + + tree.Release() + + var nilTree *Tree[string] + nilTree.Release() + assert.Equal(t, 0, nilTree.Size()) + _, _, found = nilTree.Lookup("/users/123") + assert.False(t, found) + assert.False(t, nilTree.Insert("/ignored", "ignored")) + nilTree.Clear() +} + +func TestTree_InsertReinitializesReleasedTree(t *testing.T) { + tree := New[string]() + tree.Release() + + assert.True(t, tree.Insert("/users/{id}", "user")) + + value, path, found := tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "/users/{id}", path) + assert.Equal(t, "user", value) +} + func TestTree_Lookup_EdgeCases(t *testing.T) { tree := New[string]() diff --git a/requests/request_body.go b/requests/request_body.go index 1646890..c7517d4 100644 --- a/requests/request_body.go +++ b/requests/request_body.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package requests @@ -26,6 +26,9 @@ type RequestBodyValidator interface { // request body is valid, false if it is not. The second return value will be a slice of ValidationError pointers if // the body is not valid. ValidateRequestBodyWithPathItem(request *http.Request, pathItem *v3.PathItem, pathValue string) (bool, []*errors.ValidationError) + + // Release clears validator-owned options and drops the OpenAPI document reference. + Release() } // NewRequestBodyValidator will create a new RequestBodyValidator from an OpenAPI 3+ document @@ -39,3 +42,14 @@ type requestBodyValidator struct { options *config.ValidationOptions document *v3.Document } + +func (r *requestBodyValidator) Release() { + if r == nil { + return + } + if r.options != nil { + r.options.Release() + r.options = nil + } + r.document = nil +} diff --git a/requests/request_body_test.go b/requests/request_body_test.go new file mode 100644 index 0000000..f9992da --- /dev/null +++ b/requests/request_body_test.go @@ -0,0 +1,49 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package requests + +import ( + "testing" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + + "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/config" +) + +func TestRequestBodyValidator_Release(t *testing.T) { + schemaCache := cache.NewDefaultCache() + schemaCache.Store(1, &cache.SchemaCacheEntry{RenderedInline: []byte("schema")}) + + resourceCache := cache.NewDefaultSchemaResourceCache() + resourceCache.Store("resource", &cache.SchemaResourceCacheEntry{RenderedInline: []byte("resource")}) + + v := NewRequestBodyValidator( + &v3.Document{}, + config.WithSchemaCache(schemaCache), + config.WithSchemaResourceCache(resourceCache), + ) + + validator := v.(*requestBodyValidator) + require.NotNil(t, validator.options) + require.NotNil(t, validator.document) + + v.Release() + + assert.Nil(t, validator.options) + assert.Nil(t, validator.document) + + _, schemaFound := schemaCache.Load(1) + assert.False(t, schemaFound) + + _, resourceFound := resourceCache.Load("resource") + assert.False(t, resourceFound) + + v.Release() + + var nilValidator *requestBodyValidator + nilValidator.Release() +} diff --git a/requests/validate_request.go b/requests/validate_request.go index 2dc1810..b04ac87 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -283,12 +283,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror // flatten the validationErrors schFlatErrs := helpers.FlattenSchemaOutputErrors(jk.DetailedOutput()) - // Use cached node if available, otherwise parse - renderedNode := cachedNode - if renderedNode == nil { - renderedNode = new(yaml.Node) - _ = yaml.Unmarshal(renderedSchema, renderedNode) - } + renderedNode, resourceNodes := schema_validation.DiagnosticLocationNodes(renderedSchema, cachedNode, resourceNodes) for q := range schFlatErrs { er := schFlatErrs[q] diff --git a/responses/response_body.go b/responses/response_body.go index 62bac3f..4d8e8ff 100644 --- a/responses/response_body.go +++ b/responses/response_body.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package responses @@ -26,6 +26,9 @@ type ResponseBodyValidator interface { // locate the operation in the specification, the response is used to ensure the response code, media type and the // schema of the response body are valid. ValidateResponseBodyWithPathItem(request *http.Request, response *http.Response, pathItem *v3.PathItem, pathFound string) (bool, []*errors.ValidationError) + + // Release clears validator-owned options and drops the OpenAPI document reference. + Release() } // NewResponseBodyValidator will create a new ResponseBodyValidator from an OpenAPI 3+ document @@ -39,3 +42,14 @@ type responseBodyValidator struct { options *config.ValidationOptions document *v3.Document } + +func (r *responseBodyValidator) Release() { + if r == nil { + return + } + if r.options != nil { + r.options.Release() + r.options = nil + } + r.document = nil +} diff --git a/responses/response_body_test.go b/responses/response_body_test.go new file mode 100644 index 0000000..0db8ba1 --- /dev/null +++ b/responses/response_body_test.go @@ -0,0 +1,49 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package responses + +import ( + "testing" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + + "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/config" +) + +func TestResponseBodyValidator_Release(t *testing.T) { + schemaCache := cache.NewDefaultCache() + schemaCache.Store(1, &cache.SchemaCacheEntry{RenderedInline: []byte("schema")}) + + resourceCache := cache.NewDefaultSchemaResourceCache() + resourceCache.Store("resource", &cache.SchemaResourceCacheEntry{RenderedInline: []byte("resource")}) + + v := NewResponseBodyValidator( + &v3.Document{}, + config.WithSchemaCache(schemaCache), + config.WithSchemaResourceCache(resourceCache), + ) + + validator := v.(*responseBodyValidator) + require.NotNil(t, validator.options) + require.NotNil(t, validator.document) + + v.Release() + + assert.Nil(t, validator.options) + assert.Nil(t, validator.document) + + _, schemaFound := schemaCache.Load(1) + assert.False(t, schemaFound) + + _, resourceFound := resourceCache.Load("resource") + assert.False(t, resourceFound) + + v.Release() + + var nilValidator *responseBodyValidator + nilValidator.Release() +} diff --git a/schema_validation/directional_schema.go b/schema_validation/directional_schema.go index c3ecfb2..1260516 100644 --- a/schema_validation/directional_schema.go +++ b/schema_validation/directional_schema.go @@ -4,6 +4,7 @@ package schema_validation import ( + "encoding/json" "fmt" "math" @@ -51,15 +52,18 @@ func RenderSchemaForValidation(schema *base.Schema, purpose SchemaValidationPurp } renderCtx := base.NewInlineRenderContextForValidation() - renderedInline, err := schema.RenderInlineWithContext(renderCtx) + nodeIface, err := schema.MarshalYAMLInlineWithContext(renderCtx) + renderedNode, _ := nodeIface.(*yaml.Node) if err != nil { + renderedInline, _ := yaml.Marshal(renderedNode) return &RenderedValidationSchema{ RenderedInline: renderedInline, ReferenceSchema: string(renderedInline), + RenderedNode: renderedNode, }, err } - return renderSchemaBytesForValidation(renderedInline, purpose) + return renderSchemaNodeForValidation(renderedNode, purpose) } func renderSchemaBytesForValidation(renderedInline []byte, purpose SchemaValidationPurpose) (*RenderedValidationSchema, error) { @@ -68,15 +72,14 @@ func renderSchemaBytesForValidation(renderedInline []byte, purpose SchemaValidat return nil, fmt.Errorf("schema render decode failed: %w", err) } - if len(renderedNode.Content) > 0 { - pruneDirectionalRequired(renderedNode.Content[0], purpose) - } + return renderSchemaNodeForValidation(renderedNode, purpose) +} - if purpose != SchemaValidationPurposeGeneric { - renderedInline, _ = yaml.Marshal(renderedNode) - } +func renderSchemaNodeForValidation(renderedNode *yaml.Node, purpose SchemaValidationPurpose) (*RenderedValidationSchema, error) { + pruneDirectionalRequired(schemaRootNode(renderedNode), purpose) - renderedJSON, _ := utils.ConvertYAMLtoJSON(renderedInline) + renderedInline, _ := yaml.Marshal(renderedNode) + renderedJSON := renderSchemaNodeJSON(renderedNode, renderedInline) return &RenderedValidationSchema{ RenderedInline: renderedInline, @@ -86,6 +89,28 @@ func renderSchemaBytesForValidation(renderedInline []byte, purpose SchemaValidat }, nil } +func renderSchemaNodeJSON(renderedNode *yaml.Node, renderedInline []byte) []byte { + var jsonValue any + if rootNode := schemaRootNode(renderedNode); rootNode != nil { + if err := rootNode.Decode(&jsonValue); err == nil { + renderedJSON, _ := json.Marshal(jsonValue) + return renderedJSON + } + } + renderedJSON, _ := utils.ConvertYAMLtoJSON(renderedInline) + return renderedJSON +} + +func schemaRootNode(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + func pruneDirectionalRequired(schemaNode *yaml.Node, purpose SchemaValidationPurpose) { if schemaNode == nil || schemaNode.Kind != yaml.MappingNode { return @@ -200,7 +225,7 @@ func boolMappingValue(node *yaml.Node, key string) bool { if value == nil || value.Kind != yaml.ScalarNode { return false } - return value.Tag == "!!bool" && value.Value == "true" + return (value.Tag == "!!bool" || value.Tag == "") && value.Value == "true" } func removeMappingPair(node *yaml.Node, keyIndex int) { diff --git a/schema_validation/directional_schema_test.go b/schema_validation/directional_schema_test.go index ff23486..a7aa5ab 100644 --- a/schema_validation/directional_schema_test.go +++ b/schema_validation/directional_schema_test.go @@ -110,6 +110,14 @@ func TestRenderSchemaBytesForValidation_Errors(t *testing.T) { assert.Contains(t, err.Error(), "schema render decode failed") } +func TestRenderSchemaNodeJSON_Fallbacks(t *testing.T) { + assert.Nil(t, schemaRootNode(nil)) + + renderedJSON := renderSchemaNodeJSON(nil, []byte("type: object\n")) + + assert.JSONEq(t, `{"type":"object"}`, string(renderedJSON)) +} + func TestRenderSchemaBytesForValidation_RemovesEmptyRequired(t *testing.T) { rendered, err := renderSchemaBytesForValidation([]byte(`type: object required: diff --git a/schema_validation/locate_schema_property.go b/schema_validation/locate_schema_property.go index 3a44570..9d70bd1 100644 --- a/schema_validation/locate_schema_property.go +++ b/schema_validation/locate_schema_property.go @@ -51,9 +51,15 @@ func locateSchemaPropertyNodeByKeywordLocation( ) *yaml.Node { resourceName, pointer := splitKeywordLocation(location) sourceNode := doc - if resourceName != "" && resourceNodes != nil { - if resourceNode := lookupResourceNode(resourceNodes, resourceName); resourceNode != nil { - sourceNode = resourceNode + if resourceNodes != nil { + if resourceName != "" { + if resourceNode := lookupResourceNode(resourceNodes, resourceName); resourceNode != nil { + sourceNode = resourceNode + } + } else if strings.HasPrefix(location, "#") { + if resourceNode := resourceNodes[""]; resourceNode != nil { + sourceNode = resourceNode + } } } return LocateSchemaPropertyNodeByJSONPath(rootContentNode(sourceNode), pointer) diff --git a/schema_validation/locate_schema_property_test.go b/schema_validation/locate_schema_property_test.go index feb3089..3e3e1fb 100644 --- a/schema_validation/locate_schema_property_test.go +++ b/schema_validation/locate_schema_property_test.go @@ -85,6 +85,36 @@ func TestLocateSchemaPropertyNodeByJSONPathWithResources_UsesExternalResourceNod assert.Equal(t, "integer", located.Value) } +func TestLocateSchemaPropertyNodeByJSONPathWithResources_UsesEntryResourceForLocalPointer(t *testing.T) { + var entry yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Entry: + type: object + properties: + id: + type: string`), &entry)) + + var fallback yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Entry: + type: object + properties: + id: + type: integer`), &fallback)) + + located := LocateSchemaPropertyNodeByJSONPathWithResources( + fallback.Content[0], + map[string]*yaml.Node{"": entry.Content[0]}, + "/components/schemas/Missing/properties/id/type", + "#/components/schemas/Entry/properties/id/type", + ) + + require.NotNil(t, located) + assert.Equal(t, "string", located.Value) +} + func TestSplitKeywordLocation_DoesNotTreatLocalPointerHashAsResource(t *testing.T) { resourceName, pointer := splitKeywordLocation("/components/schemas/Model#v1#beta/properties/id/type") diff --git a/schema_validation/schema_resources.go b/schema_validation/schema_resources.go index 07f238d..dc4f7c6 100644 --- a/schema_validation/schema_resources.go +++ b/schema_validation/schema_resources.go @@ -69,11 +69,16 @@ func CompileSchemaForValidation( return nil, err } - if singleSchemaCompilePreferred(schema, rendered) { + if purpose == SchemaValidationPurposeGeneric && singleSchemaCompilePreferred(rendered) { return compileSingleValidationSchema(schema, rendered, options, version) } - resourceSet, err := buildSchemaDocumentResources(schema, purpose) + var resourceCache cache.SchemaResourceCache + if options != nil { + resourceCache = options.SchemaResourceCache + } + + resourceSet, err := buildSchemaDocumentResources(schema, purpose, resourceCache) if err != nil { return nil, err } @@ -197,13 +202,32 @@ func renderRootSchemaForValidation(schema *base.Schema, purpose SchemaValidation return renderSchemaBytesForValidation(renderedInline, purpose) } -// singleSchemaCompilePreferred reports whether the schema can safely use the one-resource compiler path. +// singleSchemaCompilePreferred reports whether the rendered schema is already self-contained. // -// The decision is based on reachable schema-keyword refs in the source tree, not -// a rendered-output scan, so examples, property names, enum values, or const -// values that literally contain "$ref" do not force the slower resource graph. -func singleSchemaCompilePreferred(schema *base.Schema, rendered *RenderedValidationSchema) bool { - return rendered != nil && !schemaHasReachableRefs(schema) +// Source schemas can contain refs that the inline renderer resolves cheaply. The +// document-resource compiler is needed only when refs survive rendering, usually +// because the schema graph is circular or otherwise must stay addressable. +func singleSchemaCompilePreferred(rendered *RenderedValidationSchema) bool { + return rendered != nil && !renderedSchemaHasReachableRefs(rendered) +} + +func renderedSchemaHasReachableRefs(rendered *RenderedValidationSchema) bool { + if rendered == nil { + return false + } + if rendered.RenderedNode != nil { + return hasSchemaRefValue(rendered.RenderedNode) + } + if len(rendered.RenderedInline) == 0 { + return false + } + + var renderedNode yaml.Node + if err := yaml.Unmarshal(rendered.RenderedInline, &renderedNode); err != nil { + // Invalid YAML should fall back to the resource compiler, which returns the real compile error. + return true + } + return hasSchemaRefValue(&renderedNode) } // schemaHasReachableRefs checks whether the entry schema tree contains refs that jsonschema must resolve. @@ -211,7 +235,7 @@ func schemaHasReachableRefs(schema *base.Schema) bool { if schema == nil || schema.GoLow() == nil { return false } - return len(collectSchemaRefValues(schema.GoLow().GetRootNode())) > 0 + return hasSchemaRefValue(schema.GoLow().GetRootNode()) } // renderYAMLNodeForValidation renders a YAML node to the YAML and JSON forms used by jsonschema. @@ -219,6 +243,8 @@ func schemaHasReachableRefs(schema *base.Schema) bool { // Request and response validation prune directional "required" markers, so they // clone before mutating. Generic validation does not prune, which keeps the // whole-document resource path allocation-light for ordinary document schemas. +// Generic rendered nodes may be cached, so downstream diagnostic code must keep +// treating them as read-only. func renderYAMLNodeForValidation(node *yaml.Node, purpose SchemaValidationPurpose) (*RenderedValidationSchema, error) { if node == nil { return nil, nil @@ -256,6 +282,7 @@ func renderYAMLNodeForValidation(node *yaml.Node, purpose SchemaValidationPurpos func buildSchemaDocumentResources( schema *base.Schema, purpose SchemaValidationPurpose, + resourceCache cache.SchemaResourceCache, ) (*schemaDocumentResourceSet, error) { if schema == nil || schema.GoLow() == nil { return nil, nil @@ -291,6 +318,7 @@ func buildSchemaDocumentResources( rootResourceName, schemaIndex.GetRootNode(), purpose, + resourceCache, ) if err != nil { return nil, err @@ -302,6 +330,7 @@ func buildSchemaDocumentResources( schemaIndex, schema.GoLow().GetRootNode(), purpose, + resourceCache, ); err != nil { return nil, err } @@ -324,6 +353,7 @@ func addReachableSchemaResources( currentIndex *index.SpecIndex, node *yaml.Node, purpose SchemaValidationPurpose, + resourceCache cache.SchemaResourceCache, ) error { if resourceSet == nil || state == nil || currentIndex == nil || node == nil { return nil @@ -354,12 +384,13 @@ func addReachableSchemaResources( resourceName, foundIndex.GetRootNode(), purpose, + resourceCache, ); err != nil { return err } } - if err := addReachableSchemaResources(resourceSet, state, foundIndex, foundRef.Node, purpose); err != nil { + if err := addReachableSchemaResources(resourceSet, state, foundIndex, foundRef.Node, purpose, resourceCache); err != nil { return err } } @@ -397,23 +428,80 @@ func addSchemaDocumentResource( resourceName string, rootNode *yaml.Node, purpose SchemaValidationPurpose, + resourceCache cache.SchemaResourceCache, ) (*RenderedValidationSchema, error) { if resourceName == "" || rootNode == nil { return nil, nil } + cacheKey := schemaResourceCacheKey(rootNode, purpose) + if resourceCache != nil && cacheKey != "" { + if cached, ok := resourceCache.Load(cacheKey); ok && cached != nil { + rendered := renderedSchemaFromResourceCache(cached) + registerSchemaDocumentResource(resources, resourceNodes, resourceName, rendered) + return rendered, nil + } + } rendered, err := renderYAMLNodeForValidation(rootNode, purpose) if err != nil { return nil, fmt.Errorf("schema resource %q render failed: %w", resourceName, err) } + if resourceCache != nil && cacheKey != "" { + resourceCache.Store(cacheKey, resourceCacheEntryFromRenderedSchema(rendered, rootNode)) + } + registerSchemaDocumentResource(resources, resourceNodes, resourceName, rendered) + return rendered, nil +} +func registerSchemaDocumentResource( + resources map[string][]byte, + resourceNodes map[string]*yaml.Node, + resourceName string, + rendered *RenderedValidationSchema, +) { + if rendered == nil { + return + } if resources != nil { resources[resourceName] = rendered.RenderedJSON } if resourceNodes != nil { resourceNodes[resourceName] = rendered.RenderedNode } - return rendered, nil +} + +func schemaResourceCacheKey(rootNode *yaml.Node, purpose SchemaValidationPurpose) string { + if rootNode == nil { + return "" + } + // Cache entries retain rootNode, so this parsed-document identity key cannot + // be reused by another document while the entry remains cached. + return fmt.Sprintf("%p:%d", rootNode, purpose) +} + +func resourceCacheEntryFromRenderedSchema(rendered *RenderedValidationSchema, sourceRootNode *yaml.Node) *cache.SchemaResourceCacheEntry { + if rendered == nil { + return nil + } + return &cache.SchemaResourceCacheEntry{ + RenderedInline: rendered.RenderedInline, + ReferenceSchema: rendered.ReferenceSchema, + RenderedJSON: rendered.RenderedJSON, + RenderedNode: rendered.RenderedNode, + SourceRootNode: sourceRootNode, + } +} + +func renderedSchemaFromResourceCache(cached *cache.SchemaResourceCacheEntry) *RenderedValidationSchema { + if cached == nil { + return nil + } + return &RenderedValidationSchema{ + RenderedInline: cached.RenderedInline, + ReferenceSchema: cached.ReferenceSchema, + RenderedJSON: cached.RenderedJSON, + RenderedNode: cached.RenderedNode, + } } // resourceNameForIndex returns the canonical resource URI for an indexed document. @@ -453,6 +541,48 @@ func collectSchemaRefValues(node *yaml.Node) []string { return refs } +func hasSchemaRefValue(node *yaml.Node) bool { + return hasSchemaRefValueIn(node, "") +} + +func hasSchemaRefValueIn(node *yaml.Node, parentKey string) bool { + if node == nil { + return false + } + + switch node.Kind { + case yaml.DocumentNode: + for _, child := range node.Content { + if hasSchemaRefValueIn(child, parentKey) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + key := "" + if keyNode != nil { + key = keyNode.Value + } + if isSchemaRefPair(key, parentKey, valueNode) { + return true + } + if hasSchemaRefValueIn(valueNode, key) { + return true + } + } + case yaml.SequenceNode: + for _, child := range node.Content { + if hasSchemaRefValueIn(child, parentKey) { + return true + } + } + } + + return false +} + // collectSchemaRefValuesInto walks a YAML tree and records only real schema $ref keywords. // // OpenAPI schema maps can legally contain schema names or property names called @@ -476,7 +606,7 @@ func collectSchemaRefValuesInto(node *yaml.Node, parentKey string, refs *[]strin if keyNode != nil { key = keyNode.Value } - if key == "$ref" && !isSchemaNameMap(parentKey) && valueNode != nil && valueNode.Kind == yaml.ScalarNode { + if isSchemaRefPair(key, parentKey, valueNode) { *refs = append(*refs, valueNode.Value) continue } @@ -499,6 +629,11 @@ func isSchemaNameMap(parentKey string) bool { } } +func isSchemaRefPair(key, parentKey string, valueNode *yaml.Node) bool { + return key == "$ref" && !isSchemaNameMap(parentKey) && + valueNode != nil && valueNode.Kind == yaml.ScalarNode +} + // cloneYAMLNode deep-copies a YAML node tree before validation-specific pruning mutates it. func cloneYAMLNode(node *yaml.Node) *yaml.Node { if node == nil { diff --git a/schema_validation/schema_resources_test.go b/schema_validation/schema_resources_test.go index 628f2a9..27eba22 100644 --- a/schema_validation/schema_resources_test.go +++ b/schema_validation/schema_resources_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/pb33f/libopenapi" + validatorcache "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/high/base" lowbase "github.com/pb33f/libopenapi/datamodel/low/base" @@ -21,6 +22,34 @@ import ( "github.com/pb33f/libopenapi-validator/config" ) +type countingSchemaResourceCache struct { + delegate validatorcache.SchemaResourceCache + loads int + stores int +} + +func newCountingSchemaResourceCache() *countingSchemaResourceCache { + return &countingSchemaResourceCache{delegate: validatorcache.NewDefaultSchemaResourceCache()} +} + +func (c *countingSchemaResourceCache) Load(key string) (*validatorcache.SchemaResourceCacheEntry, bool) { + c.loads++ + return c.delegate.Load(key) +} + +func (c *countingSchemaResourceCache) Store(key string, value *validatorcache.SchemaResourceCacheEntry) { + c.stores++ + c.delegate.Store(key, value) +} + +func (c *countingSchemaResourceCache) Range(f func(key string, value *validatorcache.SchemaResourceCacheEntry) bool) { + c.delegate.Range(f) +} + +func (c *countingSchemaResourceCache) Release() { + c.delegate.Release() +} + func TestJSONPointerForNode_EscapesMappingKeysAndSequences(t *testing.T) { spec := `paths: /objects: @@ -155,14 +184,13 @@ components: require.NoError(t, yaml.Unmarshal([]byte(`$ref: '#/components/schemas/Name'`), &detached)) schema.GoLow().RootNode = detached.Content[0] - compiled, err := CompileSchemaForValidation( + resourceSet, err := buildSchemaDocumentResources( schema, SchemaValidationPurposeGeneric, - config.NewValidationOptions(), - 3.1, + nil, ) - assert.Nil(t, compiled) + assert.Nil(t, resourceSet) require.Error(t, err) assert.Contains(t, err.Error(), "schema node was not found") }) @@ -218,7 +246,7 @@ components: require.ErrorIs(t, err, assert.AnError) } -func TestSingleSchemaCompilePreferred_LocalReferenceSchemaUsesResourceCompiler(t *testing.T) { +func TestSingleSchemaCompilePreferred_ResolvedLocalReferenceUsesSingleSchemaCompiler(t *testing.T) { spec := `openapi: 3.1.0 info: title: Test @@ -241,16 +269,176 @@ components: inlineSchema := model.Model.Components.Schemas.GetOrZero("Inline").Schema() inlineRendered, err := renderRootSchemaForValidation(inlineSchema, SchemaValidationPurposeGeneric) require.NoError(t, err) - assert.True(t, singleSchemaCompilePreferred(inlineSchema, inlineRendered)) + assert.True(t, singleSchemaCompilePreferred(inlineRendered)) referencedSchema := model.Model.Components.Schemas.GetOrZero("Referenced").Schema() referencedRendered, err := renderRootSchemaForValidation(referencedSchema, SchemaValidationPurposeGeneric) require.NoError(t, err) - assert.False(t, singleSchemaCompilePreferred(referencedSchema, referencedRendered)) + assert.True(t, singleSchemaCompilePreferred(referencedRendered)) assert.True(t, schemaHasReachableRefs(referencedSchema)) + assert.False(t, renderedSchemaHasReachableRefs(referencedRendered)) +} + +func TestCompileSchemaForValidation_ReusesRenderedDocumentResourceAcrossSchemas(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + First: + type: object + properties: + child: + $ref: '#/components/schemas/Node' + Second: + type: object + properties: + child: + $ref: '#/components/schemas/Node' + Node: + type: object + required: [id] + properties: + id: + type: string + next: + $ref: '#/components/schemas/Node'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + resourceCache := newCountingSchemaResourceCache() + options := config.NewValidationOptions(config.WithSchemaResourceCache(resourceCache)) + + firstCompiled, err := CompileSchemaForValidation( + model.Model.Components.Schemas.GetOrZero("First").Schema(), + SchemaValidationPurposeGeneric, + options, + 3.1, + ) + require.NoError(t, err) + require.NotNil(t, firstCompiled) + require.NotNil(t, firstCompiled.CompiledSchema) + assert.NoError(t, firstCompiled.CompiledSchema.Validate(map[string]any{ + "child": map[string]any{ + "id": "root", + "next": map[string]any{ + "id": "nested", + }, + }, + })) + + secondCompiled, err := CompileSchemaForValidation( + model.Model.Components.Schemas.GetOrZero("Second").Schema(), + SchemaValidationPurposeGeneric, + options, + 3.1, + ) + require.NoError(t, err) + require.NotNil(t, secondCompiled) + require.NotNil(t, secondCompiled.CompiledSchema) + assert.NoError(t, secondCompiled.CompiledSchema.Validate(map[string]any{ + "child": map[string]any{ + "id": "root", + }, + })) + + assert.Equal(t, 2, resourceCache.loads) + assert.Equal(t, 1, resourceCache.stores) } -func TestSingleSchemaCompilePreferred_ExternalResourceSchemaUsesResourceCompiler(t *testing.T) { +func TestCompileSchemaForValidation_ReusesDirectionalResourcesByPurpose(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Wrapper: + type: object + required: [product] + properties: + product: + $ref: '#/components/schemas/Product' + Product: + type: object + required: [id, name, secret] + properties: + id: + type: string + readOnly: true + name: + type: string + secret: + type: string + writeOnly: true` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Components.Schemas.GetOrZero("Wrapper").Schema() + resourceCache := newCountingSchemaResourceCache() + options := config.NewValidationOptions(config.WithSchemaResourceCache(resourceCache)) + + requestCompiled, err := CompileSchemaForValidation(schema, SchemaValidationPurposeRequestBody, options, 3.1) + require.NoError(t, err) + require.NotNil(t, requestCompiled) + assert.NoError(t, requestCompiled.CompiledSchema.Validate(map[string]any{ + "product": map[string]any{ + "name": "Desk", + "secret": "internal", + }, + })) + assert.Error(t, requestCompiled.CompiledSchema.Validate(map[string]any{ + "product": map[string]any{ + "name": "Desk", + }, + })) + + responseCompiled, err := CompileSchemaForValidation(schema, SchemaValidationPurposeResponseBody, options, 3.1) + require.NoError(t, err) + require.NotNil(t, responseCompiled) + assert.NoError(t, responseCompiled.CompiledSchema.Validate(map[string]any{ + "product": map[string]any{ + "id": "p1", + "name": "Desk", + }, + })) + assert.Error(t, responseCompiled.CompiledSchema.Validate(map[string]any{ + "product": map[string]any{ + "name": "Desk", + }, + })) + + requestCompiled, err = CompileSchemaForValidation(schema, SchemaValidationPurposeRequestBody, options, 3.1) + require.NoError(t, err) + require.NotNil(t, requestCompiled) + assert.NoError(t, requestCompiled.CompiledSchema.Validate(map[string]any{ + "product": map[string]any{ + "name": "Desk", + "secret": "internal", + }, + })) + + var cacheKeys []string + resourceCache.Range(func(key string, value *validatorcache.SchemaResourceCacheEntry) bool { + cacheKeys = append(cacheKeys, key) + require.NotNil(t, value.SourceRootNode) + return true + }) + + assert.Equal(t, 3, resourceCache.loads) + assert.Equal(t, 2, resourceCache.stores) + require.Len(t, cacheKeys, 2) + assert.NotEqual(t, cacheKeys[0], cacheKeys[1]) +} + +func TestSingleSchemaCompilePreferred_ResolvedExternalReferenceUsesSingleSchemaCompiler(t *testing.T) { tempDir := t.TempDir() rootPath := filepath.Join(tempDir, "openapi.yaml") require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 @@ -285,10 +473,11 @@ components: referencedSchema := model.Model.Components.Schemas.GetOrZero("Referenced").Schema() referencedRendered, err := renderRootSchemaForValidation(referencedSchema, SchemaValidationPurposeGeneric) require.NoError(t, err) - assert.False(t, singleSchemaCompilePreferred(referencedSchema, referencedRendered)) + assert.True(t, singleSchemaCompilePreferred(referencedRendered)) + assert.False(t, renderedSchemaHasReachableRefs(referencedRendered)) require.True(t, schemaHasReachableRefs(referencedSchema)) - resourceSet, err := buildSchemaDocumentResources(referencedSchema, SchemaValidationPurposeGeneric) + resourceSet, err := buildSchemaDocumentResources(referencedSchema, SchemaValidationPurposeGeneric, nil) require.NoError(t, err) require.NotNil(t, resourceSet) assert.Len(t, resourceSet.resources, 2) @@ -355,9 +544,9 @@ components: inlineRendered, err := renderRootSchemaForValidation(inlineSchema, SchemaValidationPurposeGeneric) require.NoError(t, err) - assert.True(t, singleSchemaCompilePreferred(inlineSchema, inlineRendered)) + assert.True(t, singleSchemaCompilePreferred(inlineRendered)) assert.False(t, schemaHasReachableRefs(inlineSchema)) - resourceSet, err := buildSchemaDocumentResources(inlineSchema, SchemaValidationPurposeGeneric) + resourceSet, err := buildSchemaDocumentResources(inlineSchema, SchemaValidationPurposeGeneric, nil) require.NoError(t, err) assert.Nil(t, resourceSet) } @@ -366,6 +555,7 @@ func TestBuildSchemaDocumentResources_NoLowSchema(t *testing.T) { resourceSet, err := buildSchemaDocumentResources( &base.Schema{}, SchemaValidationPurposeGeneric, + nil, ) assert.Nil(t, resourceSet) @@ -373,6 +563,29 @@ func TestBuildSchemaDocumentResources_NoLowSchema(t *testing.T) { assert.False(t, schemaHasReachableRefs(nil)) } +func TestBuildSchemaDocumentResources_IndexWithoutRootNode(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Thing: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Thing").Schema() + schema.GoLow().GetIndex().SetRootNode(nil) + + resourceSet, err := buildSchemaDocumentResources(schema, SchemaValidationPurposeGeneric, nil) + + require.NoError(t, err) + assert.Nil(t, resourceSet) +} + func TestCollectSchemaRefValues_IgnoresSchemaNameMaps(t *testing.T) { var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`type: object @@ -394,6 +607,14 @@ $defs: func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { var compiled *CompiledValidationSchema assert.Nil(t, compiled.ToCacheEntry(nil)) + assert.Empty(t, schemaResourceCacheKey(nil, SchemaValidationPurposeGeneric)) + assert.Nil(t, resourceCacheEntryFromRenderedSchema(nil, nil)) + assert.Nil(t, renderedSchemaFromResourceCache(nil)) + assert.False(t, renderedSchemaHasReachableRefs(nil)) + assert.False(t, renderedSchemaHasReachableRefs(&RenderedValidationSchema{})) + assert.False(t, renderedSchemaHasReachableRefs(&RenderedValidationSchema{RenderedInline: []byte(`type: string`)})) + assert.True(t, renderedSchemaHasReachableRefs(&RenderedValidationSchema{RenderedInline: []byte(`$ref: '#/components/schemas/Thing'`)})) + assert.True(t, renderedSchemaHasReachableRefs(&RenderedValidationSchema{RenderedInline: []byte(":\n")})) rendered, err := renderRootSchemaForValidation(nil, SchemaValidationPurposeGeneric) require.NoError(t, err) @@ -434,12 +655,17 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { resources := make(map[string][]byte) resourceNodes := make(map[string]*yaml.Node) + registerSchemaDocumentResource(resources, resourceNodes, "https://example.com/nil.json", nil) + assert.Empty(t, resources) + assert.Empty(t, resourceNodes) + rendered, err = addSchemaDocumentResource( resources, resourceNodes, "", &yaml.Node{Kind: yaml.MappingNode}, SchemaValidationPurposeGeneric, + nil, ) require.NoError(t, err) assert.Nil(t, rendered) @@ -452,6 +678,7 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { "https://example.com/root.json", nil, SchemaValidationPurposeGeneric, + nil, ) require.NoError(t, err) assert.Nil(t, rendered) @@ -460,6 +687,12 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { var resourceRoot yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`type: object`), &resourceRoot)) + cacheEntry := resourceCacheEntryFromRenderedSchema( + &RenderedValidationSchema{RenderedNode: resourceRoot.Content[0]}, + &resourceRoot, + ) + require.NotNil(t, cacheEntry) + assert.Same(t, &resourceRoot, cacheEntry.SourceRootNode) rendered, err = addSchemaDocumentResource( resources, @@ -467,12 +700,36 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { "https://example.com/root.json", &resourceRoot, SchemaValidationPurposeGeneric, + nil, ) require.NoError(t, err) require.NotNil(t, rendered) assert.NotEmpty(t, resources["https://example.com/root.json"]) assert.Same(t, rendered.RenderedNode, resourceNodes["https://example.com/root.json"]) + resourceCache := validatorcache.NewDefaultSchemaResourceCache() + resourceCache.Store(schemaResourceCacheKey(&resourceRoot, SchemaValidationPurposeGeneric), &validatorcache.SchemaResourceCacheEntry{ + RenderedInline: []byte("cached"), + ReferenceSchema: "cached", + RenderedJSON: []byte(`{"type":"object"}`), + RenderedNode: &resourceRoot, + }) + resources = make(map[string][]byte) + resourceNodes = make(map[string]*yaml.Node) + rendered, err = addSchemaDocumentResource( + resources, + resourceNodes, + "https://example.com/cached.json", + &resourceRoot, + SchemaValidationPurposeGeneric, + resourceCache, + ) + require.NoError(t, err) + require.NotNil(t, rendered) + assert.Equal(t, []byte("cached"), rendered.RenderedInline) + assert.Equal(t, []byte(`{"type":"object"}`), resources["https://example.com/cached.json"]) + assert.Same(t, &resourceRoot, resourceNodes["https://example.com/cached.json"]) + rendered, err = addSchemaDocumentResource( resources, resourceNodes, @@ -484,6 +741,7 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { }, }, SchemaValidationPurposeGeneric, + nil, ) assert.Nil(t, rendered) require.Error(t, err) @@ -508,6 +766,28 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { assert.Empty(t, ensureSchemaResourceName(nil, nil, 1)) } +func TestResourceCacheEntryPinsSourceRootForClonedDirectionalRender(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object +required: [id, name] +properties: + id: + type: string + readOnly: true + name: + type: string`), &root)) + + rendered, err := renderYAMLNodeForValidation(root.Content[0], SchemaValidationPurposeRequestBody) + require.NoError(t, err) + require.NotNil(t, rendered) + require.NotSame(t, root.Content[0], rendered.RenderedNode) + + entry := resourceCacheEntryFromRenderedSchema(rendered, root.Content[0]) + require.NotNil(t, entry) + assert.Same(t, root.Content[0], entry.SourceRootNode) + assert.Same(t, rendered.RenderedNode, entry.RenderedNode) +} + func TestRenderYAMLNodeForValidation_DirectionalPurposeClonesAndPrunes(t *testing.T) { var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`type: object @@ -545,6 +825,7 @@ func TestCanonicalResourceName_EdgeCases(t *testing.T) { func TestCollectSchemaRefValues_DocumentSequenceAndNilEdges(t *testing.T) { assert.Empty(t, collectSchemaRefValues(nil)) + assert.False(t, hasSchemaRefValueIn(nil, "")) var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`allOf: @@ -561,10 +842,11 @@ func TestCollectSchemaRefValues_DocumentSequenceAndNilEdges(t *testing.T) { "#/components/schemas/First", "#/components/schemas/Second", }, refs) + assert.True(t, hasSchemaRefValue(mappingValue(root.Content[0], "allOf"))) } func TestAddReachableSchemaResources_GuardsAndSkips(t *testing.T) { - assert.NoError(t, addReachableSchemaResources(nil, nil, nil, nil, SchemaValidationPurposeGeneric)) + assert.NoError(t, addReachableSchemaResources(nil, nil, nil, nil, SchemaValidationPurposeGeneric, nil)) var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`components: @@ -608,6 +890,7 @@ func TestAddReachableSchemaResources_GuardsAndSkips(t *testing.T) { specIndex, rootSchemaNode, SchemaValidationPurposeGeneric, + nil, )) assert.NotEmpty(t, state.seenRefs) @@ -617,6 +900,7 @@ func TestAddReachableSchemaResources_GuardsAndSkips(t *testing.T) { specIndex, childSchemaNode, SchemaValidationPurposeGeneric, + nil, )) } @@ -742,14 +1026,13 @@ components: schema := model.Model.Components.Schemas.GetOrZero("Root").Schema() appendInvalidAliasValue(schema.GoLow().GetIndex().GetRootNode()) - compiled, err := CompileSchemaForValidation( + resourceSet, err := buildSchemaDocumentResources( schema, SchemaValidationPurposeGeneric, - config.NewValidationOptions(), - 3.1, + nil, ) - assert.Nil(t, compiled) + assert.Nil(t, resourceSet) require.Error(t, err) assert.Contains(t, err.Error(), "schema resource") } @@ -796,14 +1079,13 @@ components: require.NotNil(t, grandIndex) appendInvalidAliasValue(grandIndex.GetRootNode()) - compiled, err := CompileSchemaForValidation( + resourceSet, err := buildSchemaDocumentResources( schema, SchemaValidationPurposeGeneric, - config.NewValidationOptions(), - 3.1, + nil, ) - assert.Nil(t, compiled) + assert.Nil(t, resourceSet) require.Error(t, err) assert.Contains(t, err.Error(), "schema resource") } @@ -894,6 +1176,57 @@ components: })) } +func TestSchemaValidator_LocalRecursiveReferenceReportsSourceLocation(t *testing.T) { + spec := `openapi: "3.1.0" +info: + title: Test + version: "1" +paths: + /: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Node' +components: + schemas: + Name: + type: string + Node: + type: object + properties: + name: + $ref: '#/components/schemas/Name' + next: + $ref: '#/components/schemas/Node'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + validator := NewSchemaValidator() + schema := model.Model.Components.Schemas.GetOrZero("Node").Schema() + valid, validationErrors := validator.ValidateSchemaString(schema, `{"name": 42, "next": {"name": "ok"}}`) + + assert.False(t, valid) + require.Len(t, validationErrors, 1) + require.NotEmpty(t, validationErrors[0].SchemaValidationErrors) + failureIndex := -1 + for i, candidate := range validationErrors[0].SchemaValidationErrors { + if candidate != nil && strings.Contains(candidate.Reason, "got number") { + failureIndex = i + break + } + } + require.NotEqual(t, -1, failureIndex) + failure := validationErrors[0].SchemaValidationErrors[failureIndex] + assert.Equal(t, 16, failure.Line) + assert.Greater(t, failure.Column, 0) + assert.Contains(t, failure.KeywordLocation, "/type") +} + func TestCompileSchemaForValidation_DirectionalRequiredAcrossReferences(t *testing.T) { spec := `openapi: 3.1.0 info: diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index abb8d47..425eb59 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -64,6 +64,9 @@ type SchemaValidator interface { // When version is 3.0, OpenAPI 3.0-specific keywords like 'nullable' are allowed and processed. // When version is 3.1+, OpenAPI 3.0-specific keywords like 'nullable' will cause validation to fail. ValidateSchemaBytesWithVersion(schema *base.Schema, payload []byte, version float32) (bool, []*liberrors.ValidationError) + + // Release clears validator-owned options and logger references. + Release() } var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`) @@ -88,6 +91,17 @@ func NewSchemaValidator(opts ...config.Option) SchemaValidator { return NewSchemaValidatorWithLogger(logger, opts...) } +func (s *schemaValidator) Release() { + if s == nil { + return + } + if s.options != nil { + s.options.Release() + s.options = nil + } + s.logger = nil +} + func (s *schemaValidator) ValidateSchemaString(schema *base.Schema, payload string) (bool, []*liberrors.ValidationError) { return s.validateSchemaWithVersion(schema, []byte(payload), nil, s.logger, 3.1) } @@ -243,22 +257,7 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, // Extract property name info once before processing errors (performance optimization) propertyInfo := extractPropertyNameFromError(jk) - // Determine root content node ONCE (not per-error). - // NodeBuilder.Render() returns MappingNode directly, no DocumentNode unwrapping needed. - var rootNode *yaml.Node - if renderedNode != nil { - rootNode = renderedNode - if rootNode.Kind == yaml.DocumentNode && len(rootNode.Content) > 0 { - rootNode = rootNode.Content[0] - } - } else if len(renderedSchema) > 0 { - // Fallback: parse bytes ONCE - var docNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &docNode) - if len(docNode.Content) > 0 { - rootNode = docNode.Content[0] - } - } + rootNode, resourceNodes := DiagnosticLocationNodes(renderedSchema, renderedNode, resourceNodes) for q := range schFlatErrs { er := schFlatErrs[q] @@ -329,3 +328,53 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, } return schemaValidationErrors } + +// DiagnosticLocationNodes returns rendered-schema nodes for validation error locations. +func DiagnosticLocationNodes( + renderedSchema []byte, + renderedNode *yaml.Node, + resourceNodes map[string]*yaml.Node, +) (*yaml.Node, map[string]*yaml.Node) { + if len(renderedSchema) > 0 { + var docNode yaml.Node + if err := yaml.Unmarshal(renderedSchema, &docNode); err == nil && len(docNode.Content) > 0 { + rootNode := docNode.Content[0] + return rootNode, entryResourceNodesWithRenderedRoot(resourceNodes, renderedNode, rootNode) + } + } + + rootNode := rootContentNode(renderedNode) + return rootNode, resourceNodes +} + +func entryResourceNodesWithRenderedRoot( + resourceNodes map[string]*yaml.Node, + renderedNode *yaml.Node, + renderedRootNode *yaml.Node, +) map[string]*yaml.Node { + if len(resourceNodes) == 0 || renderedNode == nil || renderedRootNode == nil { + return resourceNodes + } + + diagnosticResourceNodes := make(map[string]*yaml.Node, len(resourceNodes)+1) + for name, resourceNode := range resourceNodes { + diagnosticResourceNodes[name] = resourceNode + } + + if _, hasEntryResource := resourceNodes[""]; !hasEntryResource { + // Resource-graph compiles need the full document for absolute local refs + // like "#/components/...", while primary keyword locations use rootNode. + diagnosticResourceNodes[""] = rootContentNode(renderedNode) + return diagnosticResourceNodes + } + + // Single-schema compiles index the entry schema by "", so swap in the + // reparsed root to keep diagnostic line/column data stable. + renderedSourceRoot := rootContentNode(renderedNode) + for name, resourceNode := range resourceNodes { + if resourceNode == renderedNode || rootContentNode(resourceNode) == renderedSourceRoot { + diagnosticResourceNodes[name] = renderedRootNode + } + } + return diagnosticResourceNodes +} diff --git a/schema_validation/validate_schema_test.go b/schema_validation/validate_schema_test.go index 6ed66df..afd5d55 100644 --- a/schema_validation/validate_schema_test.go +++ b/schema_validation/validate_schema_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package schema_validation @@ -8,6 +8,8 @@ import ( "testing" "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/testify/assert" "go.yaml.in/yaml/v4" ) @@ -44,6 +46,38 @@ paths: assert.Nil(t, foundNode) } +func TestSchemaValidator_Release(t *testing.T) { + schemaCache := cache.NewDefaultCache() + schemaCache.Store(1, &cache.SchemaCacheEntry{RenderedInline: []byte("schema")}) + + schemaResourceCache := cache.NewDefaultSchemaResourceCache() + schemaResourceCache.Store("resource", &cache.SchemaResourceCacheEntry{RenderedInline: []byte("resource")}) + + v := NewSchemaValidator( + config.WithSchemaCache(schemaCache), + config.WithSchemaResourceCache(schemaResourceCache), + ) + validator := v.(*schemaValidator) + assert.NotNil(t, validator.options) + assert.NotNil(t, validator.logger) + + v.Release() + + assert.Nil(t, validator.options) + assert.Nil(t, validator.logger) + + _, schemaFound := schemaCache.Load(1) + assert.False(t, schemaFound) + + _, resourceFound := schemaResourceCache.Load("resource") + assert.False(t, resourceFound) + + v.Release() + + var nilValidator *schemaValidator + nilValidator.Release() +} + func TestValidateSchema_SimpleValid_String(t *testing.T) { spec := `openapi: 3.1.0 paths: diff --git a/strict/types.go b/strict/types.go index 045fbee..3d11222 100644 --- a/strict/types.go +++ b/strict/types.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT // Package strict provides strict validation that detects undeclared @@ -366,6 +366,20 @@ func NewValidator(options *config.ValidationOptions, version float32) *Validator return v } +// Release clears cached validation state and drops references retained by the validator. +func (v *Validator) Release() { + if v == nil { + return + } + v.options = nil + v.logger = nil + v.localCache = nil + v.patternCache = nil + v.renderCtx = nil + v.compiledIgnorePaths = nil + v.version = 0 +} + // discardHandler is a slog.Handler that discards all log records. type discardHandler struct{} diff --git a/strict/validator_test.go b/strict/validator_test.go index 0f82bba..654d595 100644 --- a/strict/validator_test.go +++ b/strict/validator_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package strict @@ -38,6 +38,32 @@ func getSchema(t *testing.T, model *libopenapi.DocumentModel[v3.Document], name return schema } +func TestValidator_Release(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictIgnorePaths("$.body.internal")) + v := NewValidator(opts, 3.1) + require.NotNil(t, v.options) + require.NotNil(t, v.logger) + require.NotNil(t, v.localCache) + require.NotNil(t, v.patternCache) + require.NotNil(t, v.renderCtx) + require.NotNil(t, v.compiledIgnorePaths) + + v.Release() + + assert.Nil(t, v.options) + assert.Nil(t, v.logger) + assert.Nil(t, v.localCache) + assert.Nil(t, v.patternCache) + assert.Nil(t, v.renderCtx) + assert.Nil(t, v.compiledIgnorePaths) + assert.Zero(t, v.version) + + v.Release() + + var nilValidator *Validator + nilValidator.Release() +} + func TestStrictValidator_SimpleUndeclaredProperty(t *testing.T) { yml := `openapi: "3.1.0" info: diff --git a/validator.go b/validator.go index d9415a7..021750b 100644 --- a/validator.go +++ b/validator.go @@ -69,6 +69,9 @@ type Validator interface { // SetDocument will set the OpenAPI 3+ document to be validated SetDocument(document libopenapi.Document) + + // Release clears validator-owned caches and drops references to the model, document, and child validators. + Release() } // NewValidator will create a new Validator from an OpenAPI 3+ document @@ -114,6 +117,30 @@ func (v *validator) SetDocument(document libopenapi.Document) { v.document = document } +func (v *validator) Release() { + if v == nil { + return + } + releaseIfSupported(v.paramValidator) + releaseIfSupported(v.requestValidator) + releaseIfSupported(v.responseValidator) + if v.options != nil { + v.options.Release() + v.options = nil + } + v.v3Model = nil + v.document = nil + v.paramValidator = nil + v.requestValidator = nil + v.responseValidator = nil +} + +func releaseIfSupported(value any) { + if r, ok := value.(interface{ Release() }); ok { + r.Release() + } +} + func (v *validator) GetParameterValidator() parameters.ParameterValidator { return v.paramValidator } diff --git a/validator_test.go b/validator_test.go index 007b3ff..b177ba8 100644 --- a/validator_test.go +++ b/validator_test.go @@ -2162,6 +2162,75 @@ func TestCacheWarming_PopulatesCache(t *testing.T) { assert.Greater(t, count, 0, "Schema cache should have entries from request and response bodies") } +func TestValidator_Release(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /nodes: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Node' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Node' +components: + schemas: + Node: + type: object + required: [id] + properties: + id: + type: string + next: + $ref: '#/components/schemas/Node'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc) + require.Nil(t, errs) + require.NotNil(t, v) + + concrete := v.(*validator) + require.NotNil(t, concrete.options) + schemaCache := concrete.options.SchemaCache + resourceCache := concrete.options.SchemaResourceCache + pathTree, ok := concrete.options.PathTree.(interface{ Size() int }) + require.True(t, ok) + + require.Greater(t, schemaCacheEntryCount(schemaCache), 0) + require.Greater(t, schemaResourceCacheEntryCount(resourceCache), 0) + require.Greater(t, pathTree.Size(), 0) + require.NotNil(t, doc.GetSpecInfo()) + + v.Release() + + assert.Nil(t, concrete.options) + assert.Nil(t, concrete.v3Model) + assert.Nil(t, concrete.document) + assert.Nil(t, concrete.paramValidator) + assert.Nil(t, concrete.requestValidator) + assert.Nil(t, concrete.responseValidator) + assert.Equal(t, 0, schemaCacheEntryCount(schemaCache)) + assert.Equal(t, 0, schemaResourceCacheEntryCount(resourceCache)) + assert.Equal(t, 0, pathTree.Size()) + assert.NotNil(t, doc.GetSpecInfo()) + + v.Release() + + var nilValidator *validator + nilValidator.Release() +} + func TestDirectionalRequiredProperties_RequestResponseSharedSchema(t *testing.T) { spec := `openapi: 3.1.0 info: @@ -2613,6 +2682,15 @@ func schemaCacheEntryCount(schemaCache cache.SchemaCache) int { return count } +func schemaResourceCacheEntryCount(schemaResourceCache cache.SchemaResourceCache) int { + count := 0 + schemaResourceCache.Range(func(key string, value *cache.SchemaResourceCacheEntry) bool { + count++ + return true + }) + return count +} + // TestSortValidationErrors tests that validation errors are sorted deterministically func TestSortValidationErrors(t *testing.T) { // Create errors in random order