diff --git a/config/config.go b/config/config.go index 2fdea0c5..ff998dc6 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "log/slog" + "sync" "github.com/santhosh-tekuri/jsonschema/v6" @@ -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 { diff --git a/config/config_test.go b/config/config_test.go index dddd739a..daa95585 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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 @@ -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) { @@ -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) { @@ -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) { diff --git a/validator.go b/validator.go index e905dc7c..f326d7ee 100644 --- a/validator.go +++ b/validator.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "sort" + "strings" "sync" "github.com/pb33f/libopenapi" @@ -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 @@ -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 @@ -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) + } + } + } + } +}