Skip to content

Commit f7a0b49

Browse files
Implement Radix Tree
1 parent cadff75 commit f7a0b49

24 files changed

Lines changed: 2301 additions & 168 deletions

config/config.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/santhosh-tekuri/jsonschema/v6"
77

88
"github.com/pb33f/libopenapi-validator/cache"
9+
"github.com/pb33f/libopenapi-validator/radix"
910
)
1011

1112
// RegexCache can be set to enable compiled regex caching.
@@ -30,6 +31,7 @@ type ValidationOptions struct {
3031
AllowScalarCoercion bool // Enable string->boolean/number coercion
3132
Formats map[string]func(v any) error
3233
SchemaCache cache.SchemaCache // Optional cache for compiled schemas
34+
PathLookup radix.PathLookup // O(k) path lookup via radix tree (built automatically)
3335
Logger *slog.Logger // Logger for debug/error output (nil = silent)
3436

3537
// strict mode options - detect undeclared properties even when additionalProperties: true
@@ -74,6 +76,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
7476
o.AllowScalarCoercion = options.AllowScalarCoercion
7577
o.Formats = options.Formats
7678
o.SchemaCache = options.SchemaCache
79+
o.PathLookup = options.PathLookup
7780
o.Logger = options.Logger
7881
o.StrictMode = options.StrictMode
7982
o.StrictIgnorePaths = options.StrictIgnorePaths
@@ -164,9 +167,17 @@ func WithScalarCoercion() Option {
164167
// WithSchemaCache sets a custom cache implementation or disables caching if nil.
165168
// Pass nil to disable schema caching and skip cache warming during validator initialization.
166169
// The default cache is a thread-safe sync.Map wrapper.
167-
func WithSchemaCache(cache cache.SchemaCache) Option {
170+
func WithSchemaCache(schemaCache cache.SchemaCache) Option {
168171
return func(o *ValidationOptions) {
169-
o.SchemaCache = cache
172+
o.SchemaCache = schemaCache
173+
}
174+
}
175+
176+
// WithPathLookup sets a custom path lookup implementation.
177+
// The default is a radix tree built from the OpenAPI specification.
178+
func WithPathLookup(pathLookup radix.PathLookup) Option {
179+
return func(o *ValidationOptions) {
180+
o.PathLookup = pathLookup
170181
}
171182
}
172183

parameters/cookie_parameters.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
)
2121

2222
func (v *paramValidator) ValidateCookieParams(request *http.Request) (bool, []*errors.ValidationError) {
23-
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
23+
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
2424
if len(errs) > 0 {
2525
return false, errs
2626
}

parameters/cookie_parameters_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ paths:
694694
request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "2500"}) // too many dude.
695695

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

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

@@ -1145,7 +1145,7 @@ paths:
11451145
// No cookie added
11461146

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

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

parameters/header_parameters.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
)
2222

2323
func (v *paramValidator) ValidateHeaderParams(request *http.Request) (bool, []*errors.ValidationError) {
24-
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
24+
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
2525
if len(errs) > 0 {
2626
return false, errs
2727
}

parameters/header_parameters_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,7 @@ paths:
750750
request.Header.Set("coffeecups", "1200") // that's a lot of cups dude, we only have one dishwasher.
751751

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

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

parameters/path_parameters.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
)
2222

2323
func (v *paramValidator) ValidatePathParams(request *http.Request) (bool, []*errors.ValidationError) {
24-
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
24+
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
2525
if len(errs) > 0 {
2626
return false, errs
2727
}

parameters/path_parameters_test.go

Lines changed: 36 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package parameters
55

66
import (
77
"net/http"
8-
"regexp"
98
"sync"
109
"sync/atomic"
1110
"testing"
@@ -2075,7 +2074,7 @@ paths:
20752074
request, _ := http.NewRequest(http.MethodGet, "https://things.com/pizza/;burgerId=22334/locate", nil)
20762075

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

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

@@ -2271,51 +2270,6 @@ func (c *regexCacheWatcher) Store(key, value any) {
22712270
c.inner.Store(key, value)
22722271
}
22732272

2274-
func TestNewValidator_CacheCompiledRegex(t *testing.T) {
2275-
spec := `openapi: 3.1.0
2276-
paths:
2277-
/pizza:
2278-
get:
2279-
operationId: getPizza`
2280-
2281-
doc, _ := libopenapi.NewDocument([]byte(spec))
2282-
2283-
m, _ := doc.BuildV3Model()
2284-
2285-
cache := &regexCacheWatcher{inner: &sync.Map{}}
2286-
v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))
2287-
2288-
compiledPizza := regexp.MustCompile("^pizza$")
2289-
cache.inner.Store("pizza", compiledPizza)
2290-
2291-
assert.EqualValues(t, 0, cache.storeCount)
2292-
assert.EqualValues(t, 0, cache.hitCount+cache.missCount)
2293-
2294-
request, _ := http.NewRequest(http.MethodGet, "https://things.com/pizza", nil)
2295-
v.ValidatePathParams(request)
2296-
2297-
assert.EqualValues(t, 0, cache.storeCount)
2298-
assert.EqualValues(t, 0, cache.missCount)
2299-
assert.EqualValues(t, 1, cache.hitCount)
2300-
2301-
mapLength := 0
2302-
2303-
cache.inner.Range(func(key, value any) bool {
2304-
mapLength += 1
2305-
return true
2306-
})
2307-
2308-
assert.Equal(t, 1, mapLength)
2309-
2310-
cache.inner.Clear()
2311-
2312-
v.ValidatePathParams(request)
2313-
2314-
assert.EqualValues(t, 1, cache.storeCount)
2315-
assert.EqualValues(t, 1, cache.missCount)
2316-
assert.EqualValues(t, 1, cache.hitCount)
2317-
}
2318-
23192273
func TestValidatePathParamsWithPathItem_RegexCache_WithOneCached(t *testing.T) {
23202274
spec := `openapi: 3.1.0
23212275
paths:
@@ -2350,33 +2304,46 @@ paths:
23502304
assert.EqualValues(t, 1, cache.hitCount)
23512305
}
23522306

2353-
func TestValidatePathParamsWithPathItem_RegexCache_MissOnceThenHit(t *testing.T) {
2307+
// TestRadixTree_RegexFallback verifies that:
2308+
// 1. Simple paths use the radix tree (no regex cache)
2309+
// 2. Complex paths (OData style) fall back to regex and use the cache
2310+
func TestRadixTree_RegexFallback(t *testing.T) {
23542311
spec := `openapi: 3.1.0
23552312
paths:
2356-
/burgers/{burgerId}/locate:
2357-
parameters:
2358-
- in: path
2359-
name: burgerId
2360-
schema:
2361-
type: integer
2313+
/simple/{id}:
23622314
get:
2363-
operationId: locateBurgers`
2315+
operationId: getSimple
2316+
/entities('{Entity}'):
2317+
get:
2318+
operationId: getOData`
2319+
23642320
doc, _ := libopenapi.NewDocument([]byte(spec))
23652321
m, _ := doc.BuildV3Model()
23662322

23672323
cache := &regexCacheWatcher{inner: &sync.Map{}}
2368-
2369-
v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))
2370-
2371-
request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/123/locate", nil)
2372-
pathItem, _, foundPath := paths.FindPath(request, &m.Model, cache)
2373-
2374-
v.ValidatePathParamsWithPathItem(request, pathItem, foundPath)
2375-
2376-
assert.EqualValues(t, 3, cache.storeCount)
2377-
assert.EqualValues(t, 3, cache.missCount)
2378-
assert.EqualValues(t, 3, cache.hitCount)
2379-
2380-
_, found := cache.inner.Load("{burgerId}")
2381-
assert.True(t, found)
2324+
opts := &config.ValidationOptions{RegexCache: cache}
2325+
2326+
// Simple path - should NOT use regex cache (handled by radix tree)
2327+
simpleRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/simple/123", nil)
2328+
pathItem, _, foundPath := paths.FindPath(simpleRequest, &m.Model, opts)
2329+
2330+
assert.NotNil(t, pathItem)
2331+
assert.Equal(t, "/simple/{id}", foundPath)
2332+
assert.EqualValues(t, 0, cache.storeCount, "Simple paths should not use regex cache")
2333+
assert.EqualValues(t, 0, cache.hitCount+cache.missCount, "Simple paths should not touch regex cache")
2334+
2335+
// OData path - SHOULD use regex cache (radix tree can't handle embedded params)
2336+
odataRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/entities('abc')", nil)
2337+
pathItem, _, foundPath = paths.FindPath(odataRequest, &m.Model, opts)
2338+
2339+
assert.NotNil(t, pathItem)
2340+
assert.Equal(t, "/entities('{Entity}')", foundPath)
2341+
assert.EqualValues(t, 1, cache.storeCount, "OData paths should use regex cache")
2342+
assert.EqualValues(t, 1, cache.missCount, "First OData lookup should miss cache")
2343+
2344+
// Second OData call should hit cache
2345+
pathItem, _, _ = paths.FindPath(odataRequest, &m.Model, opts)
2346+
assert.NotNil(t, pathItem)
2347+
assert.EqualValues(t, 1, cache.storeCount, "No new stores on cache hit")
2348+
assert.EqualValues(t, 1, cache.hitCount, "Second OData lookup should hit cache")
23822349
}

parameters/query_parameters.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const rx = `[:\/\?#\[\]\@!\$&'\(\)\*\+,;=]`
2727
var rxRxp = regexp.MustCompile(rx)
2828

2929
func (v *paramValidator) ValidateQueryParams(request *http.Request) (bool, []*errors.ValidationError) {
30-
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
30+
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
3131
if len(errs) > 0 {
3232
return false, errs
3333
}

parameters/query_parameters_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3029,7 +3029,7 @@ paths:
30293029
"https://things.com/a/fishy/on/a/dishy?fishy[ocean]=atlantic&fishy[salt]=12", nil)
30303030

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

30343034
valid, errors := v.ValidateQueryParamsWithPathItem(request, path, pv)
30353035
assert.False(t, valid)

parameters/validate_security.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
)
1919

2020
func (v *paramValidator) ValidateSecurity(request *http.Request) (bool, []*errors.ValidationError) {
21-
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
21+
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options)
2222
if len(errs) > 0 {
2323
return false, errs
2424
}

0 commit comments

Comments
 (0)