Skip to content

Commit 02a6420

Browse files
Implement Radix Tree
1 parent cadff75 commit 02a6420

10 files changed

Lines changed: 2266 additions & 111 deletions

File tree

config/config.go

Lines changed: 5 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,9 @@ 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
170173
}
171174
}
172175

parameters/path_parameters_test.go

Lines changed: 33 additions & 67 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"
@@ -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,45 @@ 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{}}
23682324

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)
2325+
// Simple path - should NOT use regex cache (handled by radix tree)
2326+
simpleRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/simple/123", nil)
2327+
pathItem, _, foundPath := paths.FindPath(simpleRequest, &m.Model, cache)
2328+
2329+
assert.NotNil(t, pathItem)
2330+
assert.Equal(t, "/simple/{id}", foundPath)
2331+
assert.EqualValues(t, 0, cache.storeCount, "Simple paths should not use regex cache")
2332+
assert.EqualValues(t, 0, cache.hitCount+cache.missCount, "Simple paths should not touch regex cache")
2333+
2334+
// OData path - SHOULD use regex cache (radix tree can't handle embedded params)
2335+
odataRequest, _ := http.NewRequest(http.MethodGet, "https://things.com/entities('abc')", nil)
2336+
pathItem, _, foundPath = paths.FindPath(odataRequest, &m.Model, cache)
2337+
2338+
assert.NotNil(t, pathItem)
2339+
assert.Equal(t, "/entities('{Entity}')", foundPath)
2340+
assert.EqualValues(t, 1, cache.storeCount, "OData paths should use regex cache")
2341+
assert.EqualValues(t, 1, cache.missCount, "First OData lookup should miss cache")
2342+
2343+
// Second OData call should hit cache
2344+
pathItem, _, _ = paths.FindPath(odataRequest, &m.Model, cache)
2345+
assert.NotNil(t, pathItem)
2346+
assert.EqualValues(t, 1, cache.storeCount, "No new stores on cache hit")
2347+
assert.EqualValues(t, 1, cache.hitCount, "Second OData lookup should hit cache")
23822348
}

paths/paths.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"regexp"
1212
"strings"
13+
"sync"
1314

1415
"github.com/pb33f/libopenapi/orderedmap"
1516

@@ -18,20 +19,74 @@ import (
1819
"github.com/pb33f/libopenapi-validator/config"
1920
"github.com/pb33f/libopenapi-validator/errors"
2021
"github.com/pb33f/libopenapi-validator/helpers"
22+
"github.com/pb33f/libopenapi-validator/radix"
2123
)
2224

25+
// pathTreeCache caches radix trees per document to avoid rebuilding on every call.
26+
// The cache is keyed by document pointer for fast lookup.
27+
// This is only needed if you are trying to use FindPath directly. If you go through the validator,
28+
// this cache is not needed.
29+
var pathTreeCache sync.Map
30+
31+
// getOrBuildPathTree returns a cached radix tree for the document, or builds one if not cached.
32+
func getOrBuildPathTree(document *v3.Document) *radix.PathTree {
33+
if document == nil || document.Paths == nil {
34+
return nil
35+
}
36+
37+
// Use document pointer as cache key
38+
if cached, ok := pathTreeCache.Load(document); ok {
39+
return cached.(*radix.PathTree)
40+
}
41+
42+
// Build and cache the tree
43+
tree := radix.BuildPathTree(document)
44+
pathTreeCache.Store(document, tree)
45+
return tree
46+
}
47+
2348
// FindPath will find the path in the document that matches the request path. If a successful match was found, then
2449
// the first return value will be a pointer to the PathItem. The second return value will contain any validation errors
2550
// that were picked up when locating the path.
2651
// The third return value will be the path that was found in the document, as it pertains to the contract, so all path
2752
// parameters will not have been replaced with their values from the request - allowing model lookups.
2853
//
54+
// This function first tries a fast O(k) radix tree lookup (where k is path depth). If the radix tree
55+
// doesn't find a match, it falls back to regex-based matching which handles complex path patterns
56+
// like matrix-style ({;param}), label-style ({.param}), and OData-style (entities('{Entity}')).
57+
//
2958
// Path matching follows the OpenAPI specification: literal (concrete) paths take precedence over
3059
// parameterized paths, regardless of definition order in the specification.
3160
func FindPath(request *http.Request, document *v3.Document, regexCache config.RegexCache) (*v3.PathItem, []*errors.ValidationError, string) {
32-
basePaths := getBasePaths(document)
3361
stripped := StripRequestPath(request, document)
3462

63+
// Fast path: try radix tree first (O(k) where k = path depth)
64+
tree := getOrBuildPathTree(document)
65+
if tree != nil {
66+
if pathItem, matchedPath, found := tree.Lookup(stripped); found {
67+
// Verify the path has the requested method
68+
if pathHasMethod(pathItem, request.Method) {
69+
return pathItem, nil, matchedPath
70+
}
71+
// Path found but method doesn't exist
72+
validationErrors := []*errors.ValidationError{{
73+
ValidationType: helpers.ParameterValidationPath,
74+
ValidationSubType: "missingOperation",
75+
Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path),
76+
Reason: fmt.Sprintf("The %s method for that path does not exist in the specification",
77+
request.Method),
78+
SpecLine: -1,
79+
SpecCol: -1,
80+
HowToFix: errors.HowToFixPath,
81+
}}
82+
errors.PopulateValidationErrors(validationErrors, request, matchedPath)
83+
return pathItem, validationErrors, matchedPath
84+
}
85+
}
86+
87+
// Slow path: fall back to regex matching for complex paths (matrix, label, OData, etc.)
88+
basePaths := getBasePaths(document)
89+
3590
reqPathSegments := strings.Split(stripped, "/")
3691
if reqPathSegments[0] == "" {
3792
reqPathSegments = reqPathSegments[1:]

0 commit comments

Comments
 (0)