Skip to content
Draft
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 config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"log/slog"
"sync"

"github.com/santhosh-tekuri/jsonschema/v6"

Expand Down Expand Up @@ -54,6 +55,7 @@ func NewValidationOptions(opts ...Option) *ValidationOptions {
SecurityValidation: true,
OpenAPIMode: true, // Enable OpenAPI vocabulary by default
SchemaCache: cache.NewDefaultCache(), // Enable caching by default
RegexCache: &sync.Map{}, // Enable regex caching by default
}

for _, opt := range opts {
Expand Down
26 changes: 13 additions & 13 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestNewValidationOptions_Defaults(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestNewValidationOptions_WithNilOption(t *testing.T) {
Expand All @@ -35,7 +35,7 @@ func TestNewValidationOptions_WithNilOption(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithFormatAssertions(t *testing.T) {
Expand All @@ -47,7 +47,7 @@ func TestWithFormatAssertions(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithContentAssertions(t *testing.T) {
Expand All @@ -59,7 +59,7 @@ func TestWithContentAssertions(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithoutSecurityValidation(t *testing.T) {
Expand All @@ -71,7 +71,7 @@ func TestWithoutSecurityValidation(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithRegexEngine(t *testing.T) {
Expand All @@ -86,7 +86,7 @@ func TestWithRegexEngine(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithExistingOpts(t *testing.T) {
Expand Down Expand Up @@ -122,7 +122,7 @@ func TestWithExistingOpts_NilSource(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestMultipleOptions(t *testing.T) {
Expand All @@ -137,7 +137,7 @@ func TestMultipleOptions(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestOptionOverride(t *testing.T) {
Expand All @@ -154,7 +154,7 @@ func TestOptionOverride(t *testing.T) {
assert.True(t, opts.OpenAPIMode) // Default is true
assert.False(t, opts.AllowScalarCoercion) // Default is false
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithExistingOpts_PartialOverride(t *testing.T) {
Expand All @@ -174,7 +174,7 @@ func TestWithExistingOpts_PartialOverride(t *testing.T) {
)

assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.Nil(t, opts.RegexCache)
assert.Nil(t, opts.RegexCache) // Copied from original (nil/zero value)
assert.True(t, opts.FormatAssertions) // From original
assert.True(t, opts.ContentAssertions) // Reapplied, but same value
assert.False(t, opts.SecurityValidation) // From original
Expand Down Expand Up @@ -203,7 +203,7 @@ func TestComplexScenario(t *testing.T) {
assert.True(t, opts.ContentAssertions) // Added
assert.False(t, opts.SecurityValidation) // From base
assert.Nil(t, opts.RegexEngine) // Should be nil
assert.Nil(t, opts.RegexCache)
assert.Nil(t, opts.RegexCache) // Copied from baseOpts (nil/zero value)
}

func TestMultipleOptionsWithSecurityDisabled(t *testing.T) {
Expand All @@ -217,7 +217,7 @@ func TestMultipleOptionsWithSecurityDisabled(t *testing.T) {
assert.True(t, opts.ContentAssertions)
assert.False(t, opts.SecurityValidation)
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithExistingOpts_SecurityValidationCopied(t *testing.T) {
Expand Down Expand Up @@ -312,7 +312,7 @@ func TestComplexOpenAPIScenario(t *testing.T) {
assert.True(t, opts.ContentAssertions)
assert.False(t, opts.SecurityValidation)
assert.Nil(t, opts.RegexEngine)
assert.Nil(t, opts.RegexCache)
assert.NotNil(t, opts.RegexCache) // Default is enabled (like SchemaCache)
}

func TestWithExistingOpts_OpenAPIFields(t *testing.T) {
Expand Down
38 changes: 38 additions & 0 deletions validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"sort"
"strings"
"sync"

"github.com/pb33f/libopenapi"
Expand Down Expand Up @@ -99,6 +100,9 @@ func NewValidatorFromV3Model(m *v3.Document, opts ...config.Option) Validator {
// (warmSchemaCaches checks for nil cache and skips if disabled)
warmSchemaCaches(m, options)

// warm the regex cache by pre-compiling all path parameter regexes
warmRegexCache(m, options)

v := &validator{options: options, v3Model: m}

// create a new parameter validator
Expand Down Expand Up @@ -198,6 +202,12 @@ func (v *validator) ValidateHttpRequestResponse(
}

func (v *validator) ValidateHttpRequest(request *http.Request) (bool, []*errors.ValidationError) {
// Fast path: use synchronous validation for GET/HEAD/OPTIONS/DELETE requests
// without a body to avoid unnecessary goroutine and channel overhead.
if request.Body == nil || request.ContentLength == 0 {
return v.ValidateHttpRequestSync(request)
}

pathItem, errs, foundPath := paths.FindPath(request, v.v3Model, v.options)
if len(errs) > 0 {
return false, errs
Expand Down Expand Up @@ -570,3 +580,31 @@ func warmParameterSchema(param *v3.Parameter, schemaCache cache.SchemaCache, opt
}
}
}

// warmRegexCache pre-compiles all path parameter regexes in the OpenAPI document and stores them in the regex cache.
// This frontloads the compilation cost so that runtime validation doesn't need to compile regexes for path segments.
func warmRegexCache(doc *v3.Document, options *config.ValidationOptions) {
if doc == nil || doc.Paths == nil || doc.Paths.PathItems == nil || options.RegexCache == nil {
return
}

for pathPair := doc.Paths.PathItems.First(); pathPair != nil; pathPair = pathPair.Next() {
pathKey := pathPair.Key()
segments := strings.Split(pathKey, "/")
for _, segment := range segments {
if segment == "" {
continue
}
// Only compile segments that contain path parameters (have braces)
if !strings.Contains(segment, "{") {
continue
}
if _, found := options.RegexCache.Load(segment); !found {
r, err := helpers.GetRegexForPath(segment)
if err == nil {
options.RegexCache.Store(segment, r)
}
}
}
}
}