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
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 {
Copy link
Contributor Author

@its-hammer-time its-hammer-time Feb 6, 2026

Choose a reason for hiding this comment

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

I added this so that developers could disable the radix approach if they wanted to fallback to just only using the regex system. If the developer passes in nil for the PathTree then we'll honor that and not create one.

WithPathTree(nil)

We need this "set" check to distinguish between a default nil and they explicitly set nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

An alternative to this is expose a helper to explicitly disable it. Under the hood we'll still need a separate state variable to track if we should make the default or not, but I'll leave it up to you to decide what syntax you prefer. I chose the nil approach because it was similar to RegexCache, SchemaCache, etc.

WithPathTreeDisabled()

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
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
55 changes: 41 additions & 14 deletions paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,39 @@ 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)
// If no path lookup is provided, we will fall back to regex-based matching.
if options != nil && options.PathTree != nil {
if pathItem, matchedPath, found := options.PathTree.Lookup(stripped); found {
if pathHasMethod(pathItem, request.Method) {
return pathItem, nil, matchedPath
}
return pathItem, missingOperationError(request, matchedPath), 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 @@ -92,18 +114,7 @@ func FindPath(request *http.Request, document *v3.Document, regexCache config.Re
}

// path matches exist but none have the required method
validationErrors := []*errors.ValidationError{{
ValidationType: helpers.PathValidation,
ValidationSubType: helpers.ValidationMissingOperation,
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, bestOverall.path)
return bestOverall.pathItem, validationErrors, bestOverall.path
return bestOverall.pathItem, missingOperationError(request, bestOverall.path), bestOverall.path
}

// normalizePathForMatching removes the fragment from a path template unless
Expand Down Expand Up @@ -220,3 +231,19 @@ func comparePaths(mapped, requested, basePaths []string, regexCache config.Regex
r := filepath.Join(requested...)
return checkPathAgainstBase(l, r, basePaths)
}

// missingOperationError returns a validation error for when a path was found but the HTTP method doesn't exist.
func missingOperationError(request *http.Request, matchedPath string) []*errors.ValidationError {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just moved this to a function so we could reuse the same error across the two approaches

validationErrors := []*errors.ValidationError{{
ValidationType: helpers.PathValidation,
ValidationSubType: helpers.ValidationMissingOperation,
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 validationErrors
}
Loading