Skip to content
Draft
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
24 changes: 22 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)
pathTreeSet bool // Internal: true if PathTree was explicitly set via WithPathTree
Logger *slog.Logger // Logger for debug/error output (nil = silent)

// strict mode options - detect undeclared properties even when additionalProperties: true
Expand Down Expand Up @@ -74,6 +77,8 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.PathTree = options.PathTree
o.pathTreeSet = options.pathTreeSet
o.Logger = options.Logger
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
Expand Down Expand Up @@ -164,9 +169,19 @@ func WithScalarCoercion() 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.
// Pass nil to disable the radix tree and use regex-based matching only.
func WithPathTree(pathTree radix.PathLookup) Option {
return func(o *ValidationOptions) {
o.PathTree = pathTree
o.pathTreeSet = true
}
}

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

// IsPathTreeSet returns true if PathTree was explicitly configured via WithPathTree.
func (o *ValidationOptions) IsPathTreeSet() bool {
return o.pathTreeSet
}

// 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
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
Loading