Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/santhosh-tekuri/jsonschema/v6"

"github.com/pb33f/libopenapi-validator/cache"
"github.com/pb33f/libopenapi-validator/radix"
)

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

// strict mode options - detect undeclared properties even when additionalProperties: true
Expand Down Expand Up @@ -74,6 +76,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
o.AllowScalarCoercion = options.AllowScalarCoercion
o.Formats = options.Formats
o.SchemaCache = options.SchemaCache
o.PathLookup = options.PathLookup
o.Logger = options.Logger
o.StrictMode = options.StrictMode
o.StrictIgnorePaths = options.StrictIgnorePaths
Expand Down Expand Up @@ -164,9 +167,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
}
}

Expand Down
100 changes: 33 additions & 67 deletions parameters/path_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package parameters

import (
"net/http"
"regexp"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -2271,51 +2270,6 @@ func (c *regexCacheWatcher) Store(key, value any) {
c.inner.Store(key, value)
}

func TestNewValidator_CacheCompiledRegex(t *testing.T) {
spec := `openapi: 3.1.0
paths:
/pizza:
get:
operationId: getPizza`

doc, _ := libopenapi.NewDocument([]byte(spec))

m, _ := doc.BuildV3Model()

cache := &regexCacheWatcher{inner: &sync.Map{}}
v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))

compiledPizza := regexp.MustCompile("^pizza$")
cache.inner.Store("pizza", compiledPizza)

assert.EqualValues(t, 0, cache.storeCount)
assert.EqualValues(t, 0, cache.hitCount+cache.missCount)

request, _ := http.NewRequest(http.MethodGet, "https://things.com/pizza", nil)
v.ValidatePathParams(request)

assert.EqualValues(t, 0, cache.storeCount)
assert.EqualValues(t, 0, cache.missCount)
assert.EqualValues(t, 1, cache.hitCount)

mapLength := 0

cache.inner.Range(func(key, value any) bool {
mapLength += 1
return true
})

assert.Equal(t, 1, mapLength)

cache.inner.Clear()

v.ValidatePathParams(request)

assert.EqualValues(t, 1, cache.storeCount)
assert.EqualValues(t, 1, cache.missCount)
assert.EqualValues(t, 1, cache.hitCount)
}

func TestValidatePathParamsWithPathItem_RegexCache_WithOneCached(t *testing.T) {
spec := `openapi: 3.1.0
paths:
Expand Down Expand Up @@ -2350,33 +2304,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 := &regexCacheWatcher{inner: &sync.Map{}}

v := NewParameterValidator(&m.Model, config.WithRegexCache(cache))

request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/123/locate", nil)
pathItem, _, foundPath := paths.FindPath(request, &m.Model, cache)

v.ValidatePathParamsWithPathItem(request, pathItem, foundPath)

assert.EqualValues(t, 3, cache.storeCount)
assert.EqualValues(t, 3, cache.missCount)
assert.EqualValues(t, 3, cache.hitCount)

_, found := cache.inner.Load("{burgerId}")
assert.True(t, found)
// 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")
}
57 changes: 56 additions & 1 deletion paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"

"github.com/pb33f/libopenapi/orderedmap"

Expand All @@ -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:]
Expand Down
Loading