Skip to content
Open
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
15 changes: 13 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,7 @@ 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
PathLookup radix.PathLookup // O(k) path lookup via radix tree (built automatically)
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 +76,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.PathLookup = options.PathLookup
o.Logger = options.Logger
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
Expand Down Expand Up @@ -164,9 +167,17 @@ 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
}
}

// WithPathLookup sets a custom path lookup implementation.
// The default is a radix tree built from the OpenAPI specification.
func WithPathLookup(pathLookup radix.PathLookup) Option {
return func(o *ValidationOptions) {
o.PathLookup = pathLookup
}
}

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
105 changes: 36 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 Down Expand Up @@ -2075,7 +2074,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 +2270,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 +2304,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}

// 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
51 changes: 49 additions & 2 deletions paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/pb33f/libopenapi-validator/config"
"github.com/pb33f/libopenapi-validator/errors"
"github.com/pb33f/libopenapi-validator/helpers"
"github.com/pb33f/libopenapi-validator/radix"
)

// FindPath will find the path in the document that matches the request path. If a successful match was found, then
Expand All @@ -26,17 +27,52 @@ import (
// The third return value will be the path that was found in the document, as it pertains to the contract, so all path
// parameters will not have been replaced with their values from the request - allowing model lookups.
//
// This function first tries a fast O(k) radix tree lookup (where k is path depth). If the radix tree
// doesn't find a match, it falls back to regex-based matching which handles complex path patterns
// like matrix-style ({;param}), label-style ({.param}), and OData-style (entities('{Entity}')).
//
// Path matching follows the OpenAPI specification: literal (concrete) paths take precedence over
// parameterized paths, regardless of definition order in the specification.
func FindPath(request *http.Request, document *v3.Document, regexCache config.RegexCache) (*v3.PathItem, []*errors.ValidationError, string) {
basePaths := getBasePaths(document)
func FindPath(request *http.Request, document *v3.Document, options *config.ValidationOptions) (*v3.PathItem, []*errors.ValidationError, string) {
stripped := StripRequestPath(request, document)

// Fast path: try radix tree first (O(k) where k = path depth)
tree := pathLookupFrom(options, document)
if tree != nil {
if pathItem, matchedPath, found := tree.Lookup(stripped); found {
// Verify the path has the requested method
if pathHasMethod(pathItem, request.Method) {
return pathItem, nil, matchedPath
}
// Path found but method doesn't exist
validationErrors := []*errors.ValidationError{{
ValidationType: helpers.ParameterValidationPath,
ValidationSubType: "missingOperation",
Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path),
Reason: fmt.Sprintf("The %s method for that path does not exist in the specification",
request.Method),
SpecLine: -1,
SpecCol: -1,
HowToFix: errors.HowToFixPath,
}}
errors.PopulateValidationErrors(validationErrors, request, matchedPath)
return pathItem, validationErrors, matchedPath
}
}

// Slow path: fall back to regex matching for complex paths (matrix, label, OData, etc.)
basePaths := getBasePaths(document)

reqPathSegments := strings.Split(stripped, "/")
if reqPathSegments[0] == "" {
reqPathSegments = reqPathSegments[1:]
}

var regexCache config.RegexCache
if options != nil {
regexCache = options.RegexCache
}

candidates := make([]pathCandidate, 0, document.Paths.PathItems.Len())

for pair := orderedmap.First(document.Paths.PathItems); pair != nil; pair = pair.Next() {
Expand Down Expand Up @@ -220,3 +256,14 @@ func comparePaths(mapped, requested, basePaths []string, regexCache config.Regex
r := filepath.Join(requested...)
return checkPathAgainstBase(l, r, basePaths)
}

// pathLookupFrom returns the PathLookup from options, or builds one from the document.
func pathLookupFrom(options *config.ValidationOptions, document *v3.Document) radix.PathLookup {
if options != nil && options.PathLookup != nil {
return options.PathLookup
}
if document != nil && document.Paths != nil {
return radix.BuildPathTree(document)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implicitly will make it so that if someone is calling FindPaths directly they will create a new radix tree on every call.

An alternative here is that we can set the tree we made here onto the options, but if the options are used in a goroutine there may be issues with that. We could potentially lock it with a mutex, but not sure if it's worth the complexity. I imagine most people aren't calling FindPaths directly.

The other alternative is we just don't use the radix tree if you didn't set it. In other words, using the Validator constructor will support it, but if you try and access these methods directly then you won't have it.

}
return nil
}
Loading