Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/santhosh-tekuri/jsonschema/v6"

"github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi-validator/radix"
)

// RegexCache can be set to enable compiled regex caching.
Expand All @@ -30,6 +31,8 @@ type ValidationOptions struct {
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.
Expand Down Expand Up @@ -76,6 +79,8 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.PathTree = options.PathTree
o.pathTreeDisabled = options.pathTreeDisabled
o.Logger = options.Logger
o.AllowXMLBodyValidation = options.AllowXMLBodyValidation
o.AllowURLEncodedBodyValidation = options.AllowURLEncodedBodyValidation
Expand Down Expand Up @@ -184,9 +189,25 @@ func WithURLEncodedBodyValidation() Option {
// WithSchemaCache sets a custom cache implementation or disables caching if nil.
// Pass nil to disable schema caching and skip cache warming during validator initialization.
// The default cache is a thread-safe sync.Map wrapper.
func WithSchemaCache(cache cache.SchemaCache) Option {
func WithSchemaCache(schemaCache cache.SchemaCache) Option {
return func(o *ValidationOptions) {
o.SchemaCache = cache
o.SchemaCache = schemaCache
}
}

// WithPathTree sets a custom radix tree for path matching.
// The default is built automatically from the OpenAPI specification.
func WithPathTree(pathTree radix.PathLookup) Option {
return func(o *ValidationOptions) {
o.PathTree = pathTree
}
}

// DisablePathTree prevents automatic radix tree construction.
// Use this to fall back to regex-based path matching only.
func DisablePathTree() Option {
return func(o *ValidationOptions) {
o.pathTreeDisabled = true
}
}

Expand Down Expand Up @@ -253,6 +274,11 @@ var defaultIgnoredHeaders = []string{
"request-start-time", // Added by some API clients for timing
}

// IsPathTreeDisabled returns true if radix tree auto-build was disabled via DisablePathTree.
func (o *ValidationOptions) IsPathTreeDisabled() bool {
return o.pathTreeDisabled
}

// GetEffectiveStrictIgnoredHeaders returns the list of headers to ignore
// based on configuration. Returns defaults if not configured, merged list
// if extra headers were added, or replaced list if headers were fully replaced.
Expand Down
31 changes: 31 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,34 @@ func TestStrictModeWithIgnorePaths(t *testing.T) {
assert.True(t, opts.StrictMode)
assert.Equal(t, paths, opts.StrictIgnorePaths)
}

func TestWithPathTree(t *testing.T) {
// Use a mock/nil path tree — WithPathTree just sets the field
opts := NewValidationOptions(WithPathTree(nil))
assert.Nil(t, opts.PathTree)

// TestWithPathTree with a real value — use a custom implementation
// We can verify the field is set using a simple check
opts2 := &ValidationOptions{}
WithPathTree(nil)(opts2)
assert.Nil(t, opts2.PathTree)
}

func TestDisablePathTree(t *testing.T) {
opts := NewValidationOptions(DisablePathTree())
assert.True(t, opts.IsPathTreeDisabled())
}

func TestIsPathTreeDisabled_Default(t *testing.T) {
opts := NewValidationOptions()
assert.False(t, opts.IsPathTreeDisabled())
}

func TestWithExistingOpts_PathTreeFields(t *testing.T) {
original := NewValidationOptions(DisablePathTree())

opts := NewValidationOptions(WithExistingOpts(original))

assert.True(t, opts.IsPathTreeDisabled())
assert.Nil(t, opts.PathTree)
}
2 changes: 1 addition & 1 deletion parameters/cookie_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

func (v *paramValidator) ValidateCookieParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
if len(errs) > 0 {
return false, errs
}
Expand Down
4 changes: 2 additions & 2 deletions parameters/cookie_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ paths:
request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "2500"}) // too many dude.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
path, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidateCookieParamsWithPathItem(request, path, pv)

Expand Down Expand Up @@ -1145,7 +1145,7 @@ paths:
// No cookie added

// Use the WithPathItem variant
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
path, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidateCookieParamsWithPathItem(request, path, pv)

Expand Down
2 changes: 1 addition & 1 deletion parameters/header_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

func (v *paramValidator) ValidateHeaderParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
if len(errs) > 0 {
return false, errs
}
Expand Down
2 changes: 1 addition & 1 deletion parameters/header_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ paths:
request.Header.Set("coffeecups", "1200") // that's a lot of cups dude, we only have one dishwasher.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
path, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidateHeaderParamsWithPathItem(request, path, pv)

Expand Down
2 changes: 1 addition & 1 deletion parameters/path_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

func (v *paramValidator) ValidatePathParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
if len(errs) > 0 {
return false, errs
}
Expand Down
106 changes: 37 additions & 69 deletions parameters/path_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package parameters

import (
"net/http"
"regexp"
"sync"
"sync/atomic"
"testing"
Expand All @@ -17,6 +16,7 @@ import (
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/paths"
"github.com/pb33f/libopenapi-validator/radix"
)

func TestNewValidator_SimpleArrayEncodedPath(t *testing.T) {
Expand Down Expand Up @@ -2075,7 +2075,7 @@ paths:
request, _ := http.NewRequest(http.MethodGet, "https://things.com/pizza/;burgerId=22334/locate", nil)

// preset the path
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
path, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidatePathParamsWithPathItem(request, path, pv)

Expand Down Expand Up @@ -2271,51 +2271,6 @@ func (c *regexCacheWatcher) Store(key, value any) {
c.inner.Store(key, value)
}

func TestNewValidator_CacheCompiledRegex(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/pizza:
get:
operationId: getPizza`

doc, _ := libopenapi.NewDocument([]byte(spec))

m, _ := doc.BuildV3Model()

cache := &regexCacheWatcher{inner: &sync.Map{}}
v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))

compiledPizza := regexp.MustCompile("^pizza$")
cache.inner.Store("pizza", compiledPizza)

assert.EqualValues(t, 0, cache.storeCount)
assert.EqualValues(t, 0, cache.hitCount+cache.missCount)

request, _ := http.NewRequest(http.MethodGet, "https://things.com/pizza", nil)
v.ValidatePathParams(request)

assert.EqualValues(t, 0, cache.storeCount)
assert.EqualValues(t, 0, cache.missCount)
assert.EqualValues(t, 1, cache.hitCount)

mapLength := 0

cache.inner.Range(func(key, value any) bool {
mapLength += 1
return true
})

assert.Equal(t, 1, mapLength)

cache.inner.Clear()

v.ValidatePathParams(request)

assert.EqualValues(t, 1, cache.storeCount)
assert.EqualValues(t, 1, cache.missCount)
assert.EqualValues(t, 1, cache.hitCount)
}

func TestValidatePathParamsWithPathItem_RegexCache_WithOneCached(t *testing.T) {
spec := `openapi: 3.1.0
paths:
Expand Down Expand Up @@ -2350,33 +2305,46 @@ paths:
assert.EqualValues(t, 1, cache.hitCount)
}

func TestValidatePathParamsWithPathItem_RegexCache_MissOnceThenHit(t *testing.T) {
// TestRadixTree_RegexFallback verifies that:
// 1. Simple paths use the radix tree (no regex cache)
// 2. Complex paths (OData style) fall back to regex and use the cache
func TestRadixTree_RegexFallback(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/burgers/{burgerId}/locate:
parameters:
- in: path
name: burgerId
schema:
type: integer
/simple/{id}:
get:
operationId: locateBurgers`
operationId: getSimple
/entities('{Entity}'):
get:
operationId: getOData`

doc, _ := libopenapi.NewDocument([]byte(spec))
m, _ := doc.BuildV3Model()

cache := &regexCacheWatcher{inner: &sync.Map{}}

v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))

request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/123/locate", nil)
pathItem, _, foundPath := paths.FindPath(request, &m.Model, cache)

v.ValidatePathParamsWithPathItem(request, pathItem, foundPath)

assert.EqualValues(t, 3, cache.storeCount)
assert.EqualValues(t, 3, cache.missCount)
assert.EqualValues(t, 3, cache.hitCount)

_, found := cache.inner.Load("{burgerId}")
assert.True(t, found)
opts := &config.ValidationOptions{RegexCache: cache, PathTree: radix.BuildPathTree(&m.Model)}

// Simple path - should NOT use regex cache (handled by radix tree)
simpleRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/simple/123", nil)
pathItem, _, foundPath := paths.FindPath(simpleRequest, &m.Model, opts)

assert.NotNil(t, pathItem)
assert.Equal(t, "/simple/{id}", foundPath)
assert.EqualValues(t, 0, cache.storeCount, "Simple paths should not use regex cache")
assert.EqualValues(t, 0, cache.hitCount+cache.missCount, "Simple paths should not touch regex cache")

// OData path - SHOULD use regex cache (radix tree can't handle embedded params)
odataRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/entities('abc')", nil)
pathItem, _, foundPath = paths.FindPath(odataRequest, &m.Model, opts)

assert.NotNil(t, pathItem)
assert.Equal(t, "/entities('{Entity}')", foundPath)
assert.EqualValues(t, 1, cache.storeCount, "OData paths should use regex cache")
assert.EqualValues(t, 1, cache.missCount, "First OData lookup should miss cache")

// Second OData call should hit cache
pathItem, _, _ = paths.FindPath(odataRequest, &m.Model, opts)
assert.NotNil(t, pathItem)
assert.EqualValues(t, 1, cache.storeCount, "No new stores on cache hit")
assert.EqualValues(t, 1, cache.hitCount, "Second OData lookup should hit cache")
}
2 changes: 1 addition & 1 deletion parameters/query_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const rx = `[:\/\?#\[\]\@!\$&'\(\)\*\+,;=]`
var rxRxp = regexp.MustCompile(rx)

func (v *paramValidator) ValidateQueryParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
if len(errs) > 0 {
return false, errs
}
Expand Down
2 changes: 1 addition & 1 deletion parameters/query_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3029,7 +3029,7 @@ paths:
"https://things.com/a/fishy/on/a/dishy?fishy[ocean]=atlantic&fishy[salt]=12", nil)

// preset the path
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
path, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidateQueryParamsWithPathItem(request, path, pv)
assert.False(t, valid)
Expand Down
2 changes: 1 addition & 1 deletion parameters/validate_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

func (v *paramValidator) ValidateSecurity(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
if len(errs) > 0 {
return false, errs
}
Expand Down
4 changes: 2 additions & 2 deletions parameters/validate_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ paths:
v := NewParameterValidator(&m.Model)

request, _ := http.NewRequest(http.MethodPost, "https://things.com/beef", nil)
pathItem, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})
pathItem, _, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})

valid, errors := v.ValidateSecurityWithPathItem(request, pathItem, pv)
assert.False(t, valid)
Expand Down Expand Up @@ -644,7 +644,7 @@ components:
v := NewParameterValidator(&m.Model, config.WithoutSecurityValidation())

request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil)
pathItem, errs, pv := paths.FindPath(request, &m.Model, &sync.Map{})
pathItem, errs, pv := paths.FindPath(request, &m.Model, &config.ValidationOptions{RegexCache: &sync.Map{}})
assert.Nil(t, errs)

valid, errors := v.ValidateSecurityWithPathItem(request, pathItem, pv)
Expand Down
Loading