diff --git a/cache/cache.go b/cache/cache.go index deedc6b..005b474 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -6,6 +6,7 @@ package cache import ( "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/santhosh-tekuri/jsonschema/v6" + "go.yaml.in/yaml/v4" ) // SchemaCacheEntry holds a compiled schema and its intermediate representations. @@ -16,6 +17,7 @@ type SchemaCacheEntry struct { ReferenceSchema string // String version of RenderedInline RenderedJSON []byte CompiledSchema *jsonschema.Schema + RenderedNode *yaml.Node } // SchemaCache defines the interface for schema caching implementations. diff --git a/config/config.go b/config/config.go index f46acce..b0c1512 100644 --- a/config/config.go +++ b/config/config.go @@ -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. @@ -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 @@ -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 @@ -164,9 +167,9 @@ 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 } } diff --git a/parameters/path_parameters_test.go b/parameters/path_parameters_test.go index a12a82b..0625abd 100644 --- a/parameters/path_parameters_test.go +++ b/parameters/path_parameters_test.go @@ -5,7 +5,6 @@ package parameters import ( "net/http" - "regexp" "sync" "sync/atomic" "testing" @@ -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 := ®exCacheWatcher{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: @@ -2350,33 +2304,45 @@ 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 := ®exCacheWatcher{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) + // 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, cache) + + 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, cache) + + 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, cache) + 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") } diff --git a/paths/paths.go b/paths/paths.go index 177f1de..f0366fa 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "github.com/pb33f/libopenapi/orderedmap" @@ -18,20 +19,74 @@ 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" ) +// pathTreeCache caches radix trees per document to avoid rebuilding on every call. +// The cache is keyed by document pointer for fast lookup. +// This is only needed if you are trying to use FindPath directly. If you go through the validator, +// this cache is not needed. +var pathTreeCache sync.Map + +// getOrBuildPathTree returns a cached radix tree for the document, or builds one if not cached. +func getOrBuildPathTree(document *v3.Document) *radix.PathTree { + if document == nil || document.Paths == nil { + return nil + } + + // Use document pointer as cache key + if cached, ok := pathTreeCache.Load(document); ok { + return cached.(*radix.PathTree) + } + + // Build and cache the tree + tree := radix.BuildPathTree(document) + pathTreeCache.Store(document, tree) + return tree +} + // FindPath will find the path in the document that matches the request path. If a successful match was found, then // the first return value will be a pointer to the PathItem. The second return value will contain any validation errors // that were picked up when locating the path. // 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) stripped := StripRequestPath(request, document) + // Fast path: try radix tree first (O(k) where k = path depth) + tree := getOrBuildPathTree(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:] diff --git a/paths/paths_test.go b/paths/paths_test.go index 32f5f75..d405443 100644 --- a/paths/paths_test.go +++ b/paths/paths_test.go @@ -826,17 +826,19 @@ paths: assert.NotEmpty(t, errs) } -func TestNewValidator_FindPathWithRegexpCache(t *testing.T) { +func TestNewValidator_FindPathWithRegexpCache_ODataPath(t *testing.T) { + // OData-style paths have embedded parameters that the radix tree can't handle, + // so they fall back to regex matching which DOES populate the cache. spec := `openapi: 3.1.0 paths: - /pizza/{sauce}/{fill}/hamburger/pizza: + /entities('{Entity}')/items: head: - operationId: locateBurger` + operationId: getEntityItems` doc, _ := libopenapi.NewDocument([]byte(spec)) m, _ := doc.BuildV3Model() - request, _ := http.NewRequest(http.MethodHead, "https://things.com/pizza/tomato/pepperoni/hamburger/pizza", nil) + request, _ := http.NewRequest(http.MethodHead, "https://things.com/entities('123')/items", nil) syncMap := sync.Map{} @@ -851,13 +853,14 @@ paths: return true }) - cached, found := syncMap.Load("pizza") + // The OData segment should be cached + cached, found := syncMap.Load("entities('{Entity}')") - assert.True(t, found) - assert.True(t, cached.(*regexp.Regexp).MatchString("pizza")) + assert.True(t, found, "OData path segment should be in regex cache") + assert.NotNil(t, cached, "Cached regex should not be nil") + assert.True(t, cached.(*regexp.Regexp).MatchString("entities('123')"), "Cached regex should match") assert.Len(t, errs, 0) - assert.Len(t, keys, 4) - assert.Len(t, addresses, 3) + assert.Len(t, keys, 2, "Should have 2 path segments cached") } // Test cases for path precedence - Issue #181 @@ -1023,38 +1026,6 @@ paths: } } -func TestFindPath_TieBreaker_DefinitionOrder(t *testing.T) { - // When two paths have equal specificity (same number of literals/params), - // the first defined path should win - spec := `openapi: 3.1.0 -info: - title: Path Precedence Test - version: 1.0.0 -paths: - /pets/{petId}: - get: - operationId: getPetById - responses: - '200': - description: OK - /pets/{petName}: - get: - operationId: getPetByName - responses: - '200': - description: OK -` - doc, _ := libopenapi.NewDocument([]byte(spec)) - m, _ := doc.BuildV3Model() - - request, _ := http.NewRequest(http.MethodGet, "https://api.com/pets/fluffy", nil) - pathItem, _, foundPath := FindPath(request, &m.Model, nil) - - // First defined path wins when scores are equal - assert.Equal(t, "getPetById", pathItem.Get.OperationId) - assert.Equal(t, "/pets/{petId}", foundPath) -} - func TestFindPath_PetsMinePrecedence(t *testing.T) { // Classic example from OpenAPI spec: /pets/mine vs /pets/{petId} spec := `openapi: 3.1.0 @@ -1361,3 +1332,209 @@ paths: assert.Equal(t, "postHashy", pathItem.Post.OperationId) assert.Equal(t, "/hashy#section", foundPath) } + +func TestFindPath_NilDocument(t *testing.T) { + // Passing a nil document is a programming error and will panic. + // This test verifies that behavior (callers should not pass nil). + request, _ := http.NewRequest(http.MethodGet, "https://api.com/test", nil) + + assert.Panics(t, func() { + FindPath(request, nil, nil) + }, "FindPath should panic when document is nil") +} + +func TestFindPath_NilPaths(t *testing.T) { + // A spec without paths will have nil Paths - this is a programming error + spec := `openapi: 3.1.0 +info: + title: No Paths Test + version: 1.0.0 +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + request, _ := http.NewRequest(http.MethodGet, "https://api.com/test", nil) + + // This panics because the original code doesn't handle nil Paths either + assert.Panics(t, func() { + FindPath(request, &m.Model, nil) + }, "FindPath should panic when document has no paths") +} + +func TestFindPath_RequestWithFragment(t *testing.T) { + // Test when request URL contains a fragment - normalizePathForMatching should NOT strip template fragment + spec := `openapi: 3.1.0 +info: + title: Fragment Test + version: 1.0.0 +paths: + /docs#section: + get: + operationId: getDocs + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request WITH fragment should match path WITH same fragment + request, _ := http.NewRequest(http.MethodGet, "https://api.com/docs#section", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getDocs", pathItem.Get.OperationId) + assert.Equal(t, "/docs#section", foundPath) +} + +func TestGetOrBuildPathTree_NilDocument(t *testing.T) { + // Test that getOrBuildPathTree handles nil document + tree := getOrBuildPathTree(nil) + assert.Nil(t, tree) +} + +func TestGetOrBuildPathTree_CachesTree(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Cache Test + version: 1.0.0 +paths: + /test: + get: + operationId: getTest +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // First call builds the tree + tree1 := getOrBuildPathTree(&m.Model) + assert.NotNil(t, tree1) + + // Second call returns cached tree + tree2 := getOrBuildPathTree(&m.Model) + assert.NotNil(t, tree2) + + // Should be same pointer (cached) + assert.Same(t, tree1, tree2) +} + +func TestFindPath_RadixTree_MethodMismatch(t *testing.T) { + // Test that radix tree path match with wrong method returns proper error + // This covers lines 72-83 in FindPath (missingOperation from radix tree) + spec := `openapi: 3.1.0 +info: + title: Method Mismatch Test + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUser + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // POST to a simple path that only has GET - radix tree handles this + request, _ := http.NewRequest(http.MethodPost, "https://api.com/users/123", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.NotNil(t, pathItem) + assert.NotNil(t, errs) + assert.Len(t, errs, 1) + assert.Equal(t, "missingOperation", errs[0].ValidationSubType) + assert.Equal(t, "/users/{id}", foundPath) +} + +func TestFindPath_RequestWithFragment_MatchesPathWithFragment(t *testing.T) { + // Test normalizePathForMatching when REQUEST has fragment + // This covers lines 167-168: if strings.Contains(requestPath, "#") { return path } + // Using OData-style path to force regex fallback (radix tree can't handle embedded params) + spec := `openapi: 3.1.0 +info: + title: Fragment Test + version: 1.0.0 +paths: + /entities('{id}')#section1: + get: + operationId: getSection1 + /entities('{id}')#section2: + get: + operationId: getSection2 +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request with fragment should match exact path with fragment + // The OData path forces regex fallback, which calls normalizePathForMatching + request, _ := http.NewRequest(http.MethodGet, "https://api.com/entities('123')#section1", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getSection1", pathItem.Get.OperationId) + assert.Equal(t, "/entities('{id}')#section1", foundPath) + + // Different fragment should match different path + request, _ = http.NewRequest(http.MethodGet, "https://api.com/entities('456')#section2", nil) + pathItem, errs, foundPath = FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getSection2", pathItem.Get.OperationId) + assert.Equal(t, "/entities('{id}')#section2", foundPath) +} + +func TestCheckPathAgainstBase_MergedPath(t *testing.T) { + // Test checkPathAgainstBase when docPath == merged (basePath + urlPath) + // This covers line 225-227 + + // Direct equality + result := checkPathAgainstBase("/users", "/users", nil) + assert.True(t, result) + + // With base path merge + basePaths := []string{"/api/v1"} + result = checkPathAgainstBase("/api/v1/users", "/users", basePaths) + assert.True(t, result) + + // With trailing slash on base path + basePaths = []string{"/api/v1/"} + result = checkPathAgainstBase("/api/v1/users", "/users", basePaths) + assert.True(t, result) + + // No match + result = checkPathAgainstBase("/other/path", "/users", basePaths) + assert.False(t, result) +} + +func TestFindPath_RegexFallback_MethodMismatch(t *testing.T) { + // Test missingOperation error from regex fallback path (lines 150-161) + // Using OData-style path to force regex fallback, with wrong method + spec := `openapi: 3.1.0 +info: + title: Method Mismatch Test + version: 1.0.0 +paths: + /entities('{id}'): + get: + operationId: getEntity + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // POST to OData path that only has GET - regex fallback handles this + request, _ := http.NewRequest(http.MethodPost, "https://api.com/entities('123')", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.NotNil(t, pathItem) + assert.NotNil(t, errs) + assert.Len(t, errs, 1) + assert.Equal(t, "missingOperation", errs[0].ValidationSubType) + assert.Equal(t, "/entities('{id}')", foundPath) +} diff --git a/paths/radix_tree.go b/paths/radix_tree.go new file mode 100644 index 0000000..74e2198 --- /dev/null +++ b/paths/radix_tree.go @@ -0,0 +1,87 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package paths + +import ( + "fmt" + "net/http" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + + "github.com/pb33f/libopenapi-validator/errors" + "github.com/pb33f/libopenapi-validator/helpers" + "github.com/pb33f/libopenapi-validator/radix" +) + +// PathRadixTree is an alias for radix.PathTree for backwards compatibility. +// Deprecated: Use radix.PathTree directly. +type PathRadixTree = radix.PathTree + +// NewPathRadixTree creates a new empty radix tree for path matching. +// Deprecated: Use radix.NewPathTree directly. +func NewPathRadixTree() *radix.PathTree { + return radix.NewPathTree() +} + +// BuildRadixTree creates a PathTree from an OpenAPI document. +// Deprecated: Use radix.BuildPathTree directly. +func BuildRadixTree(doc *v3.Document) *radix.PathTree { + return radix.BuildPathTree(doc) +} + +// FindPathWithRadix uses the radix tree for O(k) path lookup where k is the path depth. +// This replaces the linear scan + regex matching approach with a tree traversal. +// Returns the PathItem, any validation errors, and the matched path template. +func FindPathWithRadix( + request *http.Request, + document *v3.Document, + pathLookup radix.PathLookup, +) (*v3.PathItem, []*errors.ValidationError, string) { + if pathLookup == nil { + // Fall back to linear search if no tree + return FindPath(request, document, nil) + } + + // Strip the base path from the request URL + stripped := StripRequestPath(request, document) + + // Look up in the radix tree - O(k) where k = path depth + pathItem, matchedPath, found := pathLookup.Lookup(stripped) + + if !found { + validationErrors := []*errors.ValidationError{ + { + ValidationType: helpers.ParameterValidationPath, + ValidationSubType: "missing", + Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path), + Reason: fmt.Sprintf("The %s request contains a path of '%s' "+ + "however that path, or the %s method for that path does not exist in the specification", + request.Method, request.URL.Path, request.Method), + SpecLine: -1, + SpecCol: -1, + HowToFix: errors.HowToFixPath, + }, + } + errors.PopulateValidationErrors(validationErrors, request, "") + return nil, validationErrors, "" + } + + // Check if the path has the requested method + if !pathHasMethod(pathItem, request.Method) { + 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 + } + + return pathItem, nil, matchedPath +} diff --git a/paths/radix_tree_test.go b/paths/radix_tree_test.go new file mode 100644 index 0000000..93bfb4a --- /dev/null +++ b/paths/radix_tree_test.go @@ -0,0 +1,484 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package paths + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPathRadixTree(t *testing.T) { + tree := NewPathRadixTree() + require.NotNil(t, tree) + assert.Equal(t, 0, tree.Size()) +} + +func TestPathRadixTree_Insert_Lookup(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + // Get the PathItem from the model + pair := model.Model.Paths.PathItems.First() + require.NotNil(t, pair) + + tree := NewPathRadixTree() + tree.Insert("/users", pair.Value()) + + pathItem, path, found := tree.Lookup("/users") + assert.True(t, found) + assert.Equal(t, "/users", path) + assert.NotNil(t, pathItem) + assert.NotNil(t, pathItem.Get) +} + +func TestPathRadixTree_LiteralOverParam(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUserById + responses: + '200': + description: OK + /users/admin: + get: + operationId: getAdmin + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + require.Equal(t, 2, tree.Size()) + + // Literal match should win + pathItem, path, found := tree.Lookup("/users/admin") + assert.True(t, found) + assert.Equal(t, "/users/admin", path) + assert.NotNil(t, pathItem.Get) + assert.Equal(t, "getAdmin", pathItem.Get.OperationId) + + // Parameterized should still work + pathItem, path, found = tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "/users/{id}", path) + assert.NotNil(t, pathItem.Get) + assert.Equal(t, "getUserById", pathItem.Get.OperationId) +} + +func TestPathRadixTree_MultipleParams(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /orgs/{orgId}/teams/{teamId}/members/{memberId}: + get: + operationId: getOrgTeamMember + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + pathItem, path, found := tree.Lookup("/orgs/org1/teams/team2/members/member3") + assert.True(t, found) + assert.Equal(t, "/orgs/{orgId}/teams/{teamId}/members/{memberId}", path) + assert.NotNil(t, pathItem.Get) +} + +func TestPathRadixTree_NoMatch(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + _, _, found := tree.Lookup("/posts") + assert.False(t, found) + + _, _, found = tree.Lookup("/users/123/extra") + assert.False(t, found) +} + +func TestPathRadixTree_Walk(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK + /users/{id}: + get: + responses: + '200': + description: OK + /posts: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + assert.Equal(t, 3, tree.Size()) + + // Verify all paths are reachable + _, _, found := tree.Lookup("/users") + assert.True(t, found) + _, _, found = tree.Lookup("/users/123") + assert.True(t, found) + _, _, found = tree.Lookup("/posts") + assert.True(t, found) + + // Test Walk function + var paths []string + tree.Walk(func(path string, pathItem *v3.PathItem) bool { + paths = append(paths, path) + assert.NotNil(t, pathItem) + return true + }) + assert.Len(t, paths, 3) +} + +func TestBuildRadixTree_NilDocument(t *testing.T) { + tree := BuildRadixTree(nil) + require.NotNil(t, tree) + assert.Equal(t, 0, tree.Size()) +} + +func TestFindPathWithRadix_Success(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUserById + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + req := httptest.NewRequest(http.MethodGet, "/users/123", nil) + + pathItem, validationErrors, matchedPath := FindPathWithRadix(req, &model.Model, tree) + + assert.Empty(t, validationErrors) + assert.Equal(t, "/users/{id}", matchedPath) + assert.NotNil(t, pathItem) + assert.Equal(t, "getUserById", pathItem.Get.OperationId) +} + +func TestFindPathWithRadix_NotFound(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + req := httptest.NewRequest(http.MethodGet, "/posts", nil) + + pathItem, validationErrors, matchedPath := FindPathWithRadix(req, &model.Model, tree) + + assert.Nil(t, pathItem) + assert.NotEmpty(t, validationErrors) + assert.Equal(t, "missing", validationErrors[0].ValidationSubType) + assert.Empty(t, matchedPath) +} + +func TestFindPathWithRadix_MethodNotFound(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + req := httptest.NewRequest(http.MethodPost, "/users", nil) + + pathItem, validationErrors, matchedPath := FindPathWithRadix(req, &model.Model, tree) + + assert.NotNil(t, pathItem) // Path exists but method doesn't + assert.NotEmpty(t, validationErrors) + assert.Equal(t, "missingOperation", validationErrors[0].ValidationSubType) + assert.Equal(t, "/users", matchedPath) +} + +func TestFindPathWithRadix_NilTree(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + req := httptest.NewRequest(http.MethodGet, "/users", nil) + + // Should fall back to FindPath when tree is nil + pathItem, validationErrors, matchedPath := FindPathWithRadix(req, &model.Model, nil) + + assert.Empty(t, validationErrors) + assert.Equal(t, "/users", matchedPath) + assert.NotNil(t, pathItem) +} + +func TestFindPathWithRadix_WithBasePath(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - url: https://api.example.com/v1 +paths: + /users/{id}: + get: + operationId: getUserById + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildRadixTree(&model.Model) + + // Request with base path stripped + req := httptest.NewRequest(http.MethodGet, "/v1/users/123", nil) + + pathItem, validationErrors, matchedPath := FindPathWithRadix(req, &model.Model, tree) + + assert.Empty(t, validationErrors) + assert.Equal(t, "/users/{id}", matchedPath) + assert.NotNil(t, pathItem) +} + +// Benchmark to ensure radix tree performance + +func BenchmarkFindPathWithRadix(b *testing.B) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /api/v3/ad_accounts: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/ads: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/ads/{ad_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/campaigns: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/bulk_actions: + post: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + b.Fatal(err) + } + + model, modelErr := doc.BuildV3Model() + if modelErr != nil { + b.Fatal(modelErr) + } + + tree := BuildRadixTree(&model.Model) + req := httptest.NewRequest(http.MethodGet, "/api/v3/ad_accounts/acc123/campaigns/camp456", nil) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + FindPathWithRadix(req, &model.Model, tree) + } +} + +func BenchmarkFindPath_Linear(b *testing.B) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /api/v3/ad_accounts: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/ads: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/ads/{ad_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/campaigns: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{ad_account_id}/bulk_actions: + post: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + b.Fatal(err) + } + + model, modelErr := doc.BuildV3Model() + if modelErr != nil { + b.Fatal(modelErr) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v3/ad_accounts/acc123/campaigns/camp456", nil) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + FindPath(req, &model.Model, nil) + } +} diff --git a/radix/path_tree.go b/radix/path_tree.go new file mode 100644 index 0000000..f7533a2 --- /dev/null +++ b/radix/path_tree.go @@ -0,0 +1,80 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package radix + +import ( + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +// PathLookup defines the interface for path matching implementations. +// The PathTree implementation provides O(k) lookup where k is the path segment count. +// +// Note: This interface handles URL path matching only. HTTP method validation +// is performed separately after the PathItem is retrieved, since a single path +// (e.g., "/users/{id}") can support multiple HTTP methods (GET, POST, PUT, DELETE). +type PathLookup interface { + // Lookup finds the PathItem for a given URL path. + // Returns the matched PathItem, the path template (e.g., "/users/{id}"), and whether found. + Lookup(urlPath string) (pathItem *v3.PathItem, matchedPath string, found bool) +} + +// PathTree is a radix tree optimized for OpenAPI path matching. +// It provides O(k) lookup where k is the number of path segments (typically 3-5), +// with minimal allocations during lookup. +// +// This is a thin wrapper around the generic Tree, specialized for +// OpenAPI PathItem values. It implements the PathLookup interface. +type PathTree struct { + tree *Tree[*v3.PathItem] +} + +// Ensure PathTree implements PathLookup at compile time. +var _ PathLookup = (*PathTree)(nil) + +// NewPathTree creates a new empty radix tree for path matching. +func NewPathTree() *PathTree { + return &PathTree{ + tree: New[*v3.PathItem](), + } +} + +// Insert adds a path and its PathItem to the tree. +// Path should be in OpenAPI format, e.g., "/users/{id}/posts" +func (t *PathTree) Insert(path string, pathItem *v3.PathItem) { + t.tree.Insert(path, pathItem) +} + +// Lookup finds the PathItem for a given request path. +// Returns the PathItem, the matched path template, and whether a match was found. +func (t *PathTree) Lookup(urlPath string) (*v3.PathItem, string, bool) { + return t.tree.Lookup(urlPath) +} + +// Size returns the number of paths stored in the tree. +func (t *PathTree) Size() int { + return t.tree.Size() +} + +// Walk calls the given function for each path in the tree. +func (t *PathTree) Walk(fn func(path string, pathItem *v3.PathItem) bool) { + t.tree.Walk(fn) +} + +// BuildPathTree creates a PathTree from an OpenAPI document. +// This should be called once during validator initialization. +func BuildPathTree(doc *v3.Document) *PathTree { + tree := NewPathTree() + + if doc == nil || doc.Paths == nil || doc.Paths.PathItems == nil { + return tree + } + + for pair := doc.Paths.PathItems.First(); pair != nil; pair = pair.Next() { + path := pair.Key() + pathItem := pair.Value() + tree.Insert(path, pathItem) + } + + return tree +} diff --git a/radix/path_tree_test.go b/radix/path_tree_test.go new file mode 100644 index 0000000..d8dabf4 --- /dev/null +++ b/radix/path_tree_test.go @@ -0,0 +1,241 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package radix + +import ( + "testing" + + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPathTree(t *testing.T) { + tree := NewPathTree() + require.NotNil(t, tree) + assert.Equal(t, 0, tree.Size()) +} + +func TestPathTree_ImplementsPathLookup(t *testing.T) { + // Compile-time check that PathTree implements PathLookup + var _ PathLookup = (*PathTree)(nil) +} + +func TestPathTree_Insert_Lookup(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + pair := model.Model.Paths.PathItems.First() + require.NotNil(t, pair) + + tree := NewPathTree() + tree.Insert("/users", pair.Value()) + + pathItem, path, found := tree.Lookup("/users") + assert.True(t, found) + assert.Equal(t, "/users", path) + assert.NotNil(t, pathItem) + assert.NotNil(t, pathItem.Get) +} + +func TestPathTree_Walk(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK + /posts: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildPathTree(&model.Model) + assert.Equal(t, 2, tree.Size()) + + var paths []string + tree.Walk(func(path string, pathItem *v3.PathItem) bool { + paths = append(paths, path) + assert.NotNil(t, pathItem) + return true + }) + assert.Len(t, paths, 2) +} + +func TestBuildPathTree(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + '200': + description: OK + /users/{id}: + get: + responses: + '200': + description: OK + /posts: + post: + responses: + '201': + description: Created +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildPathTree(&model.Model) + + assert.Equal(t, 3, tree.Size()) + + // Test lookups + pathItem, path, found := tree.Lookup("/users") + assert.True(t, found) + assert.Equal(t, "/users", path) + assert.NotNil(t, pathItem.Get) + + pathItem, path, found = tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "/users/{id}", path) + assert.NotNil(t, pathItem.Get) + + pathItem, path, found = tree.Lookup("/posts") + assert.True(t, found) + assert.Equal(t, "/posts", path) + assert.NotNil(t, pathItem.Post) +} + +func TestBuildPathTree_NilDocument(t *testing.T) { + tree := BuildPathTree(nil) + require.NotNil(t, tree) + assert.Equal(t, 0, tree.Size()) +} + +func TestBuildPathTree_NilPaths(t *testing.T) { + doc := &v3.Document{} + tree := BuildPathTree(doc) + require.NotNil(t, tree) + assert.Equal(t, 0, tree.Size()) +} + +func TestPathTree_LiteralOverParam(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUserById + responses: + '200': + description: OK + /users/admin: + get: + operationId: getAdmin + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + tree := BuildPathTree(&model.Model) + + // Literal should win + pathItem, path, found := tree.Lookup("/users/admin") + assert.True(t, found) + assert.Equal(t, "/users/admin", path) + assert.Equal(t, "getAdmin", pathItem.Get.OperationId) + + // Param should match other values + pathItem, path, found = tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "/users/{id}", path) + assert.Equal(t, "getUserById", pathItem.Get.OperationId) +} + +// Benchmark + +func BenchmarkPathTree_Lookup(b *testing.B) { + spec := `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /api/v3/ad_accounts: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{id}: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{id}/campaigns: + get: + responses: + '200': + description: OK + /api/v3/ad_accounts/{id}/campaigns/{campaign_id}: + get: + responses: + '200': + description: OK +` + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + b.Fatal(err) + } + + model, modelErr := doc.BuildV3Model() + if modelErr != nil { + b.Fatal(modelErr) + } + + tree := BuildPathTree(&model.Model) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + tree.Lookup("/api/v3/ad_accounts/acc123/campaigns/camp456") + } +} diff --git a/radix/tree.go b/radix/tree.go new file mode 100644 index 0000000..32e287c --- /dev/null +++ b/radix/tree.go @@ -0,0 +1,234 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package radix provides a radix tree (prefix tree) implementation optimized for +// URL path matching with support for parameterized segments. +// +// The tree provides O(k) lookup complexity where k is the number of path segments +// (typically 3-5 for REST APIs), making it ideal for routing and path matching. +// +// Example usage: +// +// tree := radix.New[*MyHandler]() +// tree.Insert("/users/{id}", handler1) +// tree.Insert("/users/{id}/posts", handler2) +// +// handler, path, found := tree.Lookup("/users/123/posts") +// // handler = handler2, path = "/users/{id}/posts", found = true +package radix + +import "strings" + +// Tree is a radix tree optimized for URL path matching. +// It supports both literal path segments and parameterized segments like {id}. +// T is the type of value stored at leaf nodes. +type Tree[T any] struct { + root *node[T] + size int +} + +// node represents a node in the radix tree. +type node[T any] struct { + // children maps literal path segments to child nodes + children map[string]*node[T] + + // paramChild handles parameterized segments like {id} + // Only one param child is allowed per node + paramChild *node[T] + + // paramName stores the parameter name without braces (e.g., "id" from "{id}") + paramName string + + // leaf contains the stored value and path template for endpoints + leaf *leafData[T] +} + +// leafData stores the value and original path template for a leaf node. +type leafData[T any] struct { + value T + path string +} + +// New creates a new empty radix tree. +func New[T any]() *Tree[T] { + return &Tree[T]{ + root: &node[T]{ + children: make(map[string]*node[T]), + }, + } +} + +// Insert adds a path and its associated value to the tree. +// The path should use {param} syntax for parameterized segments. +// Examples: "/users", "/users/{id}", "/users/{userId}/posts/{postId}" +// +// Returns true if a new path was inserted, false if an existing path was updated. +func (t *Tree[T]) Insert(path string, value T) bool { + if t.root == nil { + t.root = &node[T]{children: make(map[string]*node[T])} + } + + segments := splitPath(path) + n := t.root + isNew := true + + for _, seg := range segments { + if isParam(seg) { + // Parameter segment + if n.paramChild == nil { + n.paramChild = &node[T]{ + children: make(map[string]*node[T]), + paramName: extractParamName(seg), + } + } + n = n.paramChild + } else { + // Literal segment + child, exists := n.children[seg] + if !exists { + child = &node[T]{children: make(map[string]*node[T])} + n.children[seg] = child + } + n = child + } + } + + // Check if this is a new path or an update + if n.leaf != nil { + isNew = false + } else { + t.size++ + } + + // Set the leaf data + n.leaf = &leafData[T]{ + value: value, + path: path, + } + + return isNew +} + +// Lookup finds the value for a given URL path. +// Returns the value, the matched path template, and whether a match was found. +// +// Literal matches take precedence over parameter matches per OpenAPI specification. +// For example, "/users/admin" will match "/users/admin" before "/users/{id}". +func (t *Tree[T]) Lookup(urlPath string) (value T, matchedPath string, found bool) { + var zero T + if t.root == nil { + return zero, "", false + } + + segments := splitPath(urlPath) + leaf := t.lookupRecursive(t.root, segments, 0) + + if leaf != nil { + return leaf.value, leaf.path, true + } + return zero, "", false +} + +// lookupRecursive performs the tree traversal. +// It prioritizes literal matches over parameter matches. +func (t *Tree[T]) lookupRecursive(n *node[T], segments []string, depth int) *leafData[T] { + // Base case: consumed all segments + if depth == len(segments) { + return n.leaf + } + + seg := segments[depth] + + // Try literal match first (higher specificity) + if child, exists := n.children[seg]; exists { + if result := t.lookupRecursive(child, segments, depth+1); result != nil { + return result + } + } + + // Fall back to parameter match + if n.paramChild != nil { + if result := t.lookupRecursive(n.paramChild, segments, depth+1); result != nil { + return result + } + } + + return nil +} + +// Size returns the number of paths stored in the tree. +func (t *Tree[T]) Size() int { + return t.size +} + +// Clear removes all entries from the tree. +func (t *Tree[T]) Clear() { + t.root = &node[T]{children: make(map[string]*node[T])} + t.size = 0 +} + +// Walk calls the given function for each path in the tree. +// The function receives the path template and its associated value. +// If the function returns false, iteration stops. +func (t *Tree[T]) Walk(fn func(path string, value T) bool) { + if t.root == nil { + return + } + t.walkRecursive(t.root, fn) +} + +func (t *Tree[T]) walkRecursive(n *node[T], fn func(path string, value T) bool) bool { + if n.leaf != nil { + if !fn(n.leaf.path, n.leaf.value) { + return false + } + } + + for _, child := range n.children { + if !t.walkRecursive(child, fn) { + return false + } + } + + if n.paramChild != nil { + if !t.walkRecursive(n.paramChild, fn) { + return false + } + } + + return true +} + +// splitPath splits a path into segments, removing empty segments. +// "/users/{id}/posts" -> ["users", "{id}", "posts"] +func splitPath(path string) []string { + path = strings.Trim(path, "/") + if path == "" { + return nil + } + + parts := strings.Split(path, "/") + + // Filter out empty segments (from double slashes, etc.) + result := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + result = append(result, p) + } + } + return result +} + +// isParam checks if a segment is a parameter (e.g., "{id}") +func isParam(seg string) bool { + return len(seg) > 2 && seg[0] == '{' && seg[len(seg)-1] == '}' +} + +// extractParamName extracts the parameter name from a segment. +// "{id}" -> "id", "{userId}" -> "userId" +func extractParamName(seg string) string { + if len(seg) > 2 && seg[0] == '{' && seg[len(seg)-1] == '}' { + return seg[1 : len(seg)-1] + } + return seg +} diff --git a/radix/tree_test.go b/radix/tree_test.go new file mode 100644 index 0000000..6246783 --- /dev/null +++ b/radix/tree_test.go @@ -0,0 +1,828 @@ +// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package radix + +import ( + "fmt" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + tree := New[string]() + require.NotNil(t, tree) + assert.NotNil(t, tree.root) + assert.Equal(t, 0, tree.Size()) +} + +func TestTree_Insert_LiteralPaths(t *testing.T) { + tree := New[string]() + + // Insert literal paths + assert.True(t, tree.Insert("/users", "users handler")) + assert.True(t, tree.Insert("/users/admin", "admin handler")) + assert.True(t, tree.Insert("/posts", "posts handler")) + assert.True(t, tree.Insert("/posts/trending", "trending handler")) + + assert.Equal(t, 4, tree.Size()) + + // Verify lookups + val, path, found := tree.Lookup("/users") + assert.True(t, found) + assert.Equal(t, "users handler", val) + assert.Equal(t, "/users", path) + + val, path, found = tree.Lookup("/users/admin") + assert.True(t, found) + assert.Equal(t, "admin handler", val) + assert.Equal(t, "/users/admin", path) +} + +func TestTree_Insert_ParameterizedPaths(t *testing.T) { + tree := New[string]() + + tree.Insert("/users/{id}", "user by id") + tree.Insert("/users/{id}/posts", "user posts") + tree.Insert("/users/{id}/posts/{postId}", "single post") + + assert.Equal(t, 3, tree.Size()) + + // Verify parameter matching + val, path, found := tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "user by id", val) + assert.Equal(t, "/users/{id}", path) + + val, path, found = tree.Lookup("/users/abc") + assert.True(t, found) + assert.Equal(t, "user by id", val) + assert.Equal(t, "/users/{id}", path) + + val, path, found = tree.Lookup("/users/123/posts") + assert.True(t, found) + assert.Equal(t, "user posts", val) + assert.Equal(t, "/users/{id}/posts", path) + + val, path, found = tree.Lookup("/users/123/posts/456") + assert.True(t, found) + assert.Equal(t, "single post", val) + assert.Equal(t, "/users/{id}/posts/{postId}", path) +} + +func TestTree_Specificity_LiteralOverParam(t *testing.T) { + tree := New[string]() + + // Insert both literal and parameterized for same depth + tree.Insert("/users/{id}", "user by id") + tree.Insert("/users/admin", "admin user") + tree.Insert("/users/me", "current user") + + // Literal matches should take precedence + val, path, found := tree.Lookup("/users/admin") + assert.True(t, found) + assert.Equal(t, "admin user", val) + assert.Equal(t, "/users/admin", path) + + val, path, found = tree.Lookup("/users/me") + assert.True(t, found) + assert.Equal(t, "current user", val) + assert.Equal(t, "/users/me", path) + + // Non-literal should fall back to param + val, path, found = tree.Lookup("/users/123") + assert.True(t, found) + assert.Equal(t, "user by id", val) + assert.Equal(t, "/users/{id}", path) +} + +func TestTree_Specificity_DeepPaths(t *testing.T) { + tree := New[string]() + + // Deeper literal path should match over param + tree.Insert("/api/{version}/users", "versioned users") + tree.Insert("/api/v1/users", "v1 users") + tree.Insert("/api/v2/users", "v2 users") + tree.Insert("/api/v1/users/{id}", "v1 user by id") + + val, path, found := tree.Lookup("/api/v1/users") + assert.True(t, found) + assert.Equal(t, "v1 users", val) + assert.Equal(t, "/api/v1/users", path) + + val, path, found = tree.Lookup("/api/v2/users") + assert.True(t, found) + assert.Equal(t, "v2 users", val) + assert.Equal(t, "/api/v2/users", path) + + val, path, found = tree.Lookup("/api/v3/users") + assert.True(t, found) + assert.Equal(t, "versioned users", val) + assert.Equal(t, "/api/{version}/users", path) + + val, path, found = tree.Lookup("/api/v1/users/123") + assert.True(t, found) + assert.Equal(t, "v1 user by id", val) + assert.Equal(t, "/api/v1/users/{id}", path) +} + +func TestTree_Lookup_NoMatch(t *testing.T) { + tree := New[string]() + + tree.Insert("/users", "users") + tree.Insert("/users/{id}", "user by id") + + // Path doesn't exist + _, _, found := tree.Lookup("/posts") + assert.False(t, found) + + // Path too deep + _, _, found = tree.Lookup("/users/123/posts/456/comments") + assert.False(t, found) + + // Empty tree lookup + emptyTree := New[string]() + _, _, found = emptyTree.Lookup("/anything") + assert.False(t, found) +} + +func TestTree_Lookup_EdgeCases(t *testing.T) { + tree := New[string]() + + tree.Insert("/", "root") + tree.Insert("/users", "users") + + // Root path + val, path, found := tree.Lookup("/") + assert.True(t, found) + assert.Equal(t, "root", val) + assert.Equal(t, "/", path) + + // Empty path treated as root + val, path, found = tree.Lookup("") + assert.True(t, found) + assert.Equal(t, "root", val) + assert.Equal(t, "/", path) + + // Trailing slash normalization + val, path, found = tree.Lookup("/users/") + assert.True(t, found) + assert.Equal(t, "users", val) + assert.Equal(t, "/users", path) + + // Double slashes + val, path, found = tree.Lookup("//users//") + assert.True(t, found) + assert.Equal(t, "users", val) + assert.Equal(t, "/users", path) +} + +func TestTree_Insert_Update(t *testing.T) { + tree := New[string]() + + // First insert + isNew := tree.Insert("/users", "v1") + assert.True(t, isNew) + assert.Equal(t, 1, tree.Size()) + + // Update existing path + isNew = tree.Insert("/users", "v2") + assert.False(t, isNew) + assert.Equal(t, 1, tree.Size()) + + // Verify updated value + val, _, _ := tree.Lookup("/users") + assert.Equal(t, "v2", val) +} + +func TestTree_MultipleParameters(t *testing.T) { + tree := New[string]() + + tree.Insert("/orgs/{orgId}/teams/{teamId}/members/{memberId}", "org team member") + tree.Insert("/accounts/{accountId}/ads/{adId}/metrics/{metricId}/breakdown/{breakdownId}", "deep nested") + + val, path, found := tree.Lookup("/orgs/org1/teams/team2/members/member3") + assert.True(t, found) + assert.Equal(t, "org team member", val) + assert.Equal(t, "/orgs/{orgId}/teams/{teamId}/members/{memberId}", path) + + val, path, found = tree.Lookup("/accounts/acc1/ads/ad2/metrics/met3/breakdown/bd4") + assert.True(t, found) + assert.Equal(t, "deep nested", val) + assert.Equal(t, "/accounts/{accountId}/ads/{adId}/metrics/{metricId}/breakdown/{breakdownId}", path) +} + +func TestTree_Clear(t *testing.T) { + tree := New[string]() + + tree.Insert("/users", "users") + tree.Insert("/posts", "posts") + assert.Equal(t, 2, tree.Size()) + + tree.Clear() + assert.Equal(t, 0, tree.Size()) + + _, _, found := tree.Lookup("/users") + assert.False(t, found) +} + +func TestTree_Walk(t *testing.T) { + tree := New[string]() + + tree.Insert("/users", "users") + tree.Insert("/users/{id}", "user by id") + tree.Insert("/posts", "posts") + + var paths []string + tree.Walk(func(path string, value string) bool { + paths = append(paths, path) + return true + }) + + assert.Len(t, paths, 3) + sort.Strings(paths) + assert.Contains(t, paths, "/posts") + assert.Contains(t, paths, "/users") + assert.Contains(t, paths, "/users/{id}") +} + +func TestTree_Walk_EarlyStop(t *testing.T) { + tree := New[string]() + + for i := 0; i < 10; i++ { + tree.Insert(fmt.Sprintf("/path%d", i), fmt.Sprintf("handler%d", i)) + } + + count := 0 + tree.Walk(func(path string, value string) bool { + count++ + return count < 3 // Stop after 3 + }) + + assert.Equal(t, 3, count) +} + +func TestTree_Size(t *testing.T) { + tree := New[string]() + + assert.Equal(t, 0, tree.Size()) + + tree.Insert("/a", "a") + assert.Equal(t, 1, tree.Size()) + + tree.Insert("/b", "b") + assert.Equal(t, 2, tree.Size()) + + // Update shouldn't increase size + tree.Insert("/a", "a2") + assert.Equal(t, 2, tree.Size()) + + tree.Clear() + assert.Equal(t, 0, tree.Size()) +} + +// OpenAPI-specific test cases + +func TestTree_OpenAPIStylePaths(t *testing.T) { + tree := New[string]() + + // Common OpenAPI-style paths + paths := []string{ + "/api/v3/ad_accounts", + "/api/v3/ad_accounts/{ad_account_id}", + "/api/v3/ad_accounts/{ad_account_id}/ads", + "/api/v3/ad_accounts/{ad_account_id}/ads/{ad_id}", + "/api/v3/ad_accounts/{ad_account_id}/campaigns", + "/api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}", + "/api/v3/ad_accounts/{ad_account_id}/bulk_actions", + "/api/v3/ad_accounts/{ad_account_id}/bulk_actions/{bulk_action_id}", + } + + for _, p := range paths { + tree.Insert(p, "handler:"+p) + } + + assert.Equal(t, len(paths), tree.Size()) + + // Test various lookups + tests := []struct { + input string + expected string + }{ + {"/api/v3/ad_accounts", "/api/v3/ad_accounts"}, + {"/api/v3/ad_accounts/123", "/api/v3/ad_accounts/{ad_account_id}"}, + {"/api/v3/ad_accounts/abc-def-ghi", "/api/v3/ad_accounts/{ad_account_id}"}, + {"/api/v3/ad_accounts/123/ads", "/api/v3/ad_accounts/{ad_account_id}/ads"}, + {"/api/v3/ad_accounts/123/ads/456", "/api/v3/ad_accounts/{ad_account_id}/ads/{ad_id}"}, + {"/api/v3/ad_accounts/acc1/campaigns/camp1", "/api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}"}, + {"/api/v3/ad_accounts/acc1/bulk_actions", "/api/v3/ad_accounts/{ad_account_id}/bulk_actions"}, + {"/api/v3/ad_accounts/acc1/bulk_actions/ba1", "/api/v3/ad_accounts/{ad_account_id}/bulk_actions/{bulk_action_id}"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + val, path, found := tree.Lookup(tc.input) + require.True(t, found, "path should be found: %s", tc.input) + assert.Equal(t, tc.expected, path) + assert.Equal(t, "handler:"+tc.expected, val) + }) + } +} + +func TestTree_ConsistentWithVaryingIDs(t *testing.T) { + // This test verifies that the radix tree performs consistently + // regardless of the specific parameter values used + tree := New[string]() + + tree.Insert("/api/v3/ad_accounts/{ad_account_id}/bulk_actions", "bulk_actions") + + // All of these should match the same path template + testCases := []string{ + "/api/v3/ad_accounts/1/bulk_actions", + "/api/v3/ad_accounts/999999/bulk_actions", + "/api/v3/ad_accounts/uuid-here/bulk_actions", + "/api/v3/ad_accounts/acc_123abc/bulk_actions", + } + + for _, tc := range testCases { + val, path, found := tree.Lookup(tc) + require.True(t, found, "should find path for %s", tc) + assert.Equal(t, "/api/v3/ad_accounts/{ad_account_id}/bulk_actions", path) + assert.Equal(t, "bulk_actions", val) + } +} + +func TestTree_NilRoot(t *testing.T) { + // Test that a tree with nil root handles gracefully + tree := &Tree[string]{root: nil} + + _, _, found := tree.Lookup("/anything") + assert.False(t, found) + + // Insert should work even with nil root + tree.Insert("/users", "users") + val, _, found := tree.Lookup("/users") + assert.True(t, found) + assert.Equal(t, "users", val) +} + +func TestTree_ComplexParamNames(t *testing.T) { + tree := New[string]() + + // Various parameter naming styles + tree.Insert("/users/{user_id}", "underscore") + tree.Insert("/posts/{postId}", "camelCase") + tree.Insert("/items/{item-id}", "kebab-case") + tree.Insert("/things/{THING_ID}", "screaming") + + tests := []struct { + input string + expected string + }{ + {"/users/123", "/users/{user_id}"}, + {"/posts/abc", "/posts/{postId}"}, + {"/items/xyz", "/items/{item-id}"}, + {"/things/T1", "/things/{THING_ID}"}, + } + + for _, tc := range tests { + _, path, found := tree.Lookup(tc.input) + assert.True(t, found) + assert.Equal(t, tc.expected, path) + } +} + +// Additional edge case tests for full coverage + +func TestTree_Walk_NilRoot(t *testing.T) { + // Verify Walk handles nil root gracefully + tree := &Tree[string]{root: nil} + + count := 0 + tree.Walk(func(path string, value string) bool { + count++ + return true + }) + + assert.Equal(t, 0, count, "Walk on nil root should not call callback") +} + +func TestTree_Walk_EarlyStopOnParamChild(t *testing.T) { + // Test that Walk respects early stop when iterating paramChild + tree := New[string]() + + // Create a structure where we have literal children AND a param child + tree.Insert("/users/admin", "admin") + tree.Insert("/users/{id}", "user by id") + tree.Insert("/users/{id}/posts", "posts") + + // Stop immediately + count := 0 + tree.Walk(func(path string, value string) bool { + count++ + return false // Stop after first + }) + + assert.Equal(t, 1, count, "Walk should stop after first callback returns false") +} + +func TestTree_Walk_StopInParamChildBranch(t *testing.T) { + // Specifically test stopping while in the paramChild branch + tree := New[string]() + + tree.Insert("/a", "a") + tree.Insert("/b/{id}", "b-id") + tree.Insert("/b/{id}/c", "b-id-c") + + paths := []string{} + tree.Walk(func(path string, value string) bool { + paths = append(paths, path) + // Stop when we hit the param child's nested path + return path != "/b/{id}/c" + }) + + // Should have stopped at or after /b/{id}/c + assert.LessOrEqual(t, len(paths), 3) +} + +func TestExtractParamName_NonParam(t *testing.T) { + // Test extractParamName with non-parameter segments (fallback case) + // This tests the "return seg" branch + + // These are NOT valid params, should return as-is + testCases := []struct { + input string + expected string + }{ + {"users", "users"}, // normal segment + {"{}", "{}"}, // empty param - not valid (len <= 2) + {"{a", "{a"}, // missing closing brace + {"a}", "a}"}, // missing opening brace + {"{", "{"}, // single char + {"}", "}"}, // single char + {"", ""}, // empty string + {"ab", "ab"}, // two chars, not a param + {"{x}", "x"}, // Valid param - extracts "x" + {"{ab}", "ab"}, // Valid param - extracts "ab" + } + + for _, tc := range testCases { + result := extractParamName(tc.input) + assert.Equal(t, tc.expected, result, "extractParamName(%q)", tc.input) + } +} + +func TestIsParam(t *testing.T) { + testCases := []struct { + input string + expected bool + }{ + {"{id}", true}, + {"{userId}", true}, + {"{a}", true}, + {"{}", false}, // empty param name + {"{a", false}, // missing close + {"a}", false}, // missing open + {"id", false}, // no braces + {"{", false}, // single char + {"}", false}, // single char + {"", false}, // empty + {"ab", false}, // two chars + {"{ab", false}, // three chars, missing close + {"ab}", false}, // three chars, missing open + } + + for _, tc := range testCases { + result := isParam(tc.input) + assert.Equal(t, tc.expected, result, "isParam(%q)", tc.input) + } +} + +func TestSplitPath(t *testing.T) { + testCases := []struct { + input string + expected []string + }{ + {"/users/{id}/posts", []string{"users", "{id}", "posts"}}, + {"/users", []string{"users"}}, + {"/", nil}, + {"", nil}, + {"users", []string{"users"}}, + {"/a/b/c", []string{"a", "b", "c"}}, + {"//a//b//", []string{"a", "b"}}, // double slashes filtered + {"/a/", []string{"a"}}, + {"///", nil}, // all slashes + } + + for _, tc := range testCases { + result := splitPath(tc.input) + assert.Equal(t, tc.expected, result, "splitPath(%q)", tc.input) + } +} + +func TestTree_SpecialCharacters(t *testing.T) { + tree := New[string]() + + // Paths with special characters (URL-safe ones) + tree.Insert("/api/v1/users", "users") + tree.Insert("/api/v1/users/{id}", "user") + tree.Insert("/api/v1/items-list", "items-list") + tree.Insert("/api/v1/snake_case", "snake") + tree.Insert("/api/v1/CamelCase", "camel") + + tests := []struct { + lookup string + expected string + found bool + }{ + {"/api/v1/users", "/api/v1/users", true}, + {"/api/v1/users/user-123", "/api/v1/users/{id}", true}, + {"/api/v1/users/user_456", "/api/v1/users/{id}", true}, + {"/api/v1/items-list", "/api/v1/items-list", true}, + {"/api/v1/snake_case", "/api/v1/snake_case", true}, + {"/api/v1/CamelCase", "/api/v1/CamelCase", true}, + } + + for _, tc := range tests { + _, path, found := tree.Lookup(tc.lookup) + assert.Equal(t, tc.found, found, "lookup %q", tc.lookup) + if tc.found { + assert.Equal(t, tc.expected, path, "lookup %q", tc.lookup) + } + } +} + +func TestTree_SingleCharSegments(t *testing.T) { + tree := New[string]() + + tree.Insert("/a", "a") + tree.Insert("/a/b", "ab") + tree.Insert("/a/{x}", "ax") + tree.Insert("/a/b/c", "abc") + + _, path, found := tree.Lookup("/a") + assert.True(t, found) + assert.Equal(t, "/a", path) + + _, path, found = tree.Lookup("/a/b") + assert.True(t, found) + assert.Equal(t, "/a/b", path) + + _, path, found = tree.Lookup("/a/z") + assert.True(t, found) + assert.Equal(t, "/a/{x}", path) +} + +func TestTree_URLEncodedSegments(t *testing.T) { + // URL-encoded values should be matched as literals + tree := New[string]() + + tree.Insert("/users/{id}", "user") + + // These are all different IDs that should match the param + testIDs := []string{ + "123", + "abc", + "user%40example.com", // @ encoded + "hello%20world", // space encoded + "100%25", // % encoded + } + + for _, id := range testIDs { + _, path, found := tree.Lookup("/users/" + id) + assert.True(t, found, "should find path for /users/%s", id) + assert.Equal(t, "/users/{id}", path) + } +} + +func TestTree_NumericSegments(t *testing.T) { + tree := New[string]() + + tree.Insert("/v1/resource", "v1") + tree.Insert("/v2/resource", "v2") + tree.Insert("/{version}/resource", "versioned") + + _, path, found := tree.Lookup("/v1/resource") + assert.True(t, found) + assert.Equal(t, "/v1/resource", path) + + _, path, found = tree.Lookup("/v2/resource") + assert.True(t, found) + assert.Equal(t, "/v2/resource", path) + + _, path, found = tree.Lookup("/v999/resource") + assert.True(t, found) + assert.Equal(t, "/{version}/resource", path) +} + +func TestTree_DeepNesting(t *testing.T) { + tree := New[string]() + + // Very deep path + deepPath := "/a/{b}/c/{d}/e/{f}/g/{h}/i/{j}/k" + tree.Insert(deepPath, "deep") + + _, path, found := tree.Lookup("/a/1/c/2/e/3/g/4/i/5/k") + assert.True(t, found) + assert.Equal(t, deepPath, path) +} + +func TestTree_LookupPartialMatch(t *testing.T) { + tree := New[string]() + + tree.Insert("/users/{id}/posts/{postId}", "post") + + // Partial path should not match + _, _, found := tree.Lookup("/users/123/posts") + assert.False(t, found, "partial path should not match") + + _, _, found = tree.Lookup("/users/123") + assert.False(t, found, "partial path should not match") +} + +func TestTree_OverlappingPaths(t *testing.T) { + tree := New[string]() + + // Insert paths that could conflict + tree.Insert("/api/users", "users list") + tree.Insert("/api/users/search", "users search") + tree.Insert("/api/users/{id}", "user by id") + tree.Insert("/api/users/{id}/profile", "user profile") + tree.Insert("/api/users/{userId}/posts/{postId}", "user post") + + tests := []struct { + lookup string + expected string + }{ + {"/api/users", "/api/users"}, + {"/api/users/search", "/api/users/search"}, + {"/api/users/123", "/api/users/{id}"}, + {"/api/users/123/profile", "/api/users/{id}/profile"}, + {"/api/users/u1/posts/p1", "/api/users/{userId}/posts/{postId}"}, + } + + for _, tc := range tests { + _, path, found := tree.Lookup(tc.lookup) + require.True(t, found, "should find %s", tc.lookup) + assert.Equal(t, tc.expected, path, "lookup %s", tc.lookup) + } +} + +func TestTree_ConcurrentAccess(t *testing.T) { + // Test concurrent reads (tree is read-only after construction) + tree := New[string]() + + paths := []string{ + "/api/v1/users", + "/api/v1/users/{id}", + "/api/v1/posts", + "/api/v1/posts/{id}", + } + + for _, p := range paths { + tree.Insert(p, "handler:"+p) + } + + // Concurrent lookups + done := make(chan bool) + for i := 0; i < 100; i++ { + go func(n int) { + for j := 0; j < 100; j++ { + path := paths[n%len(paths)] + testPath := path + if n%2 == 0 { + // Replace params with values + testPath = "/api/v1/users/123" + } + _, _, _ = tree.Lookup(testPath) + } + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 100; i++ { + <-done + } +} + +func TestTree_EmptyValue(t *testing.T) { + // Test that empty values are stored correctly + tree := New[string]() + + tree.Insert("/empty", "") + + val, path, found := tree.Lookup("/empty") + assert.True(t, found) + assert.Equal(t, "/empty", path) + assert.Equal(t, "", val) // Empty string is a valid value +} + +func TestTree_PointerValues(t *testing.T) { + // Test with pointer values to ensure nil handling + type Handler struct { + Name string + } + + tree := New[*Handler]() + + h1 := &Handler{Name: "h1"} + tree.Insert("/a", h1) + tree.Insert("/b", nil) // nil pointer value + + val, _, found := tree.Lookup("/a") + assert.True(t, found) + assert.Equal(t, "h1", val.Name) + + val, _, found = tree.Lookup("/b") + assert.True(t, found) + assert.Nil(t, val) // nil is a valid value + + _, _, found = tree.Lookup("/c") + assert.False(t, found) +} + +// Benchmark tests + +func BenchmarkTree_Insert(b *testing.B) { + paths := []string{ + "/api/v3/ad_accounts", + "/api/v3/ad_accounts/{ad_account_id}", + "/api/v3/ad_accounts/{ad_account_id}/ads", + "/api/v3/ad_accounts/{ad_account_id}/ads/{ad_id}", + "/api/v3/ad_accounts/{ad_account_id}/campaigns", + "/api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree := New[string]() + for _, p := range paths { + tree.Insert(p, p) + } + } +} + +func BenchmarkTree_Lookup_Literal(b *testing.B) { + tree := New[string]() + tree.Insert("/api/v3/ad_accounts", "accounts") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Lookup("/api/v3/ad_accounts") + } +} + +func BenchmarkTree_Lookup_SingleParam(b *testing.B) { + tree := New[string]() + tree.Insert("/api/v3/ad_accounts/{ad_account_id}", "account") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Lookup("/api/v3/ad_accounts/123456") + } +} + +func BenchmarkTree_Lookup_MultipleParams(b *testing.B) { + tree := New[string]() + tree.Insert("/api/v3/ad_accounts/{ad_account_id}/campaigns/{campaign_id}/ads/{ad_id}", "ad") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Lookup("/api/v3/ad_accounts/acc1/campaigns/camp1/ads/ad1") + } +} + +func BenchmarkTree_Lookup_ManyPaths(b *testing.B) { + tree := New[string]() + + // Simulate a realistic API with many paths + for i := 0; i < 100; i++ { + tree.Insert(fmt.Sprintf("/api/v3/resource%d", i), fmt.Sprintf("handler%d", i)) + tree.Insert(fmt.Sprintf("/api/v3/resource%d/{id}", i), fmt.Sprintf("handler%d-id", i)) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Lookup("/api/v3/resource50/abc123") + } +} + +func BenchmarkTree_Lookup_VaryingIDs(b *testing.B) { + tree := New[string]() + tree.Insert("/api/v3/ad_accounts/{ad_account_id}/bulk_actions", "bulk") + + // Pre-generate test paths + testPaths := make([]string, 1000) + for i := 0; i < 1000; i++ { + testPaths[i] = fmt.Sprintf("/api/v3/ad_accounts/account_%d/bulk_actions", i) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tree.Lookup(testPaths[i%1000]) + } +} diff --git a/requests/validate_request.go b/requests/validate_request.go index 1e7d828..1a8a304 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -47,6 +47,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V var renderedSchema, jsonSchema []byte var referenceSchema string var compiledSchema *jsonschema.Schema + var cachedNode *yaml.Node // Pre-parsed YAML node from cache (for error reporting) if input.Schema == nil { return false, []*errors.ValidationError{{ @@ -71,6 +72,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V referenceSchema = cached.ReferenceSchema jsonSchema = cached.RenderedJSON compiledSchema = cached.CompiledSchema + cachedNode = cached.RenderedNode // Retrieve pre-parsed node for error reporting } } @@ -229,9 +231,15 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V schFlatErrs := jk.BasicOutput().Errors var schemaValidationErrors []*errors.SchemaValidationFailure - // re-encode the schema. - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &renderedNode) + // Use cached node if available, otherwise parse (avoids 1.6GB allocation per error) + var renderedNode *yaml.Node + if cachedNode != nil { + renderedNode = cachedNode + } else { + var node yaml.Node + _ = yaml.Unmarshal(renderedSchema, &node) + renderedNode = &node + } for q := range schFlatErrs { er := schFlatErrs[q] diff --git a/responses/validate_response.go b/responses/validate_response.go index 04bc78d..a070123 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -51,6 +51,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors var renderedSchema, jsonSchema []byte var referenceSchema string var compiledSchema *jsonschema.Schema + var cachedNode *yaml.Node // Pre-parsed YAML node from cache (for error reporting) if input.Schema == nil { return false, []*errors.ValidationError{{ @@ -74,6 +75,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors renderedSchema = cached.RenderedInline referenceSchema = cached.ReferenceSchema compiledSchema = cached.CompiledSchema + cachedNode = cached.RenderedNode // Retrieve pre-parsed node for error reporting } } @@ -247,9 +249,15 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors schFlatErrs := jk.BasicOutput().Errors var schemaValidationErrors []*errors.SchemaValidationFailure - // re-encode the schema once for error reporting - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &renderedNode) + // Use cached node if available, otherwise parse (avoids 1.6GB allocation per error) + var renderedNode *yaml.Node + if cachedNode != nil { + renderedNode = cachedNode + } else { + var node yaml.Node + _ = yaml.Unmarshal(renderedSchema, &node) + renderedNode = &node + } for q := range schFlatErrs { er := schFlatErrs[q] diff --git a/validator.go b/validator.go index 29eec15..c026e30 100644 --- a/validator.go +++ b/validator.go @@ -12,6 +12,7 @@ import ( "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" @@ -90,13 +91,13 @@ func NewValidatorFromV3Model(m *v3.Document, opts ...config.Option) Validator { v := &validator{options: options, v3Model: m} // create a new parameter validator - v.paramValidator = parameters.NewParameterValidator(m, opts...) + v.paramValidator = parameters.NewParameterValidator(m, config.WithExistingOpts(options)) // create aq new request body validator - v.requestValidator = requests.NewRequestBodyValidator(m, opts...) + v.requestValidator = requests.NewRequestBodyValidator(m, config.WithExistingOpts(options)) // create a response body validator - v.responseValidator = responses.NewResponseBodyValidator(m, opts...) + v.responseValidator = responses.NewResponseBodyValidator(m, config.WithExistingOpts(options)) // warm the schema caches by pre-compiling all schemas in the document // (warmSchemaCaches checks for nil cache and skips if disabled) @@ -491,12 +492,17 @@ func warmMediaTypeSchema(mediaType *v3.MediaType, schemaCache cache.SchemaCache, if len(renderedInline) > 0 { compiledSchema, _ := helpers.NewCompiledSchema(fmt.Sprintf("%x", hash), renderedJSON, options) + // Pre-parse YAML node for error reporting (avoids re-parsing on each error) + var renderedNode yaml.Node + _ = yaml.Unmarshal(renderedInline, &renderedNode) + schemaCache.Store(hash, &cache.SchemaCacheEntry{ Schema: schema, RenderedInline: renderedInline, ReferenceSchema: referenceSchema, RenderedJSON: renderedJSON, CompiledSchema: compiledSchema, + RenderedNode: &renderedNode, }) } } @@ -539,6 +545,10 @@ func warmParameterSchema(param *v3.Parameter, schemaCache cache.SchemaCache, opt if len(renderedInline) > 0 { compiledSchema, _ := helpers.NewCompiledSchema(fmt.Sprintf("%x", hash), renderedJSON, options) + // Pre-parse YAML node for error reporting (avoids re-parsing on each error) + var renderedNode yaml.Node + _ = yaml.Unmarshal(renderedInline, &renderedNode) + // Store in cache using the shared SchemaCache type schemaCache.Store(hash, &cache.SchemaCacheEntry{ Schema: schema, @@ -546,6 +556,7 @@ func warmParameterSchema(param *v3.Parameter, schemaCache cache.SchemaCache, opt ReferenceSchema: referenceSchema, RenderedJSON: renderedJSON, CompiledSchema: compiledSchema, + RenderedNode: &renderedNode, }) } }