Skip to content
Open
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
23 changes: 22 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
package config

import "github.com/santhosh-tekuri/jsonschema/v6"
import (
"github.com/santhosh-tekuri/jsonschema/v6"
)

// RegexCache can be set to enable compiled regex caching.
// It can be just a sync.Map, or a custom implementation with possible cleanup.
//
// Be aware that the cache should be thread safe
type RegexCache interface {
Load(key any) (value any, ok bool) // Get a compiled regex from the cache
Store(key, value any) // Set a compiled regex to the cache
}

// ValidationOptions A container for validation configuration.
//
// Generally fluent With... style functions are used to establish the desired behavior.
type ValidationOptions struct {
RegexEngine jsonschema.RegexpEngine
RegexCache RegexCache // Enable compiled regex caching
FormatAssertions bool
ContentAssertions bool
SecurityValidation bool
Expand Down Expand Up @@ -44,6 +56,7 @@ func WithExistingOpts(options *ValidationOptions) Option {
return func(o *ValidationOptions) {
if options != nil {
o.RegexEngine = options.RegexEngine
o.RegexCache = options.RegexCache
o.FormatAssertions = options.FormatAssertions
o.ContentAssertions = options.ContentAssertions
o.SecurityValidation = options.SecurityValidation
Expand All @@ -61,6 +74,14 @@ func WithRegexEngine(engine jsonschema.RegexpEngine) Option {
}
}

// WithRegexCache assigns a cache for compiled regular expressions.
// A sync.Map should be sufficient for most use cases. It does not implement any cleanup
func WithRegexCache(regexCache RegexCache) Option {
return func(o *ValidationOptions) {
o.RegexCache = regexCache
}
}

// WithFormatAssertions enables checks for 'format' assertions (such as date, date-time, uuid, etc)
func WithFormatAssertions() Option {
return func(o *ValidationOptions) {
Expand Down
26 changes: 25 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package config

import (
"sync"
"testing"

"github.com/santhosh-tekuri/jsonschema/v6"
Expand All @@ -20,6 +21,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)
}

func TestNewValidationOptions_WithNilOption(t *testing.T) {
Expand All @@ -32,6 +34,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)
}

func TestWithFormatAssertions(t *testing.T) {
Expand All @@ -43,6 +46,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)
}

func TestWithContentAssertions(t *testing.T) {
Expand All @@ -54,6 +58,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)
}

func TestWithoutSecurityValidation(t *testing.T) {
Expand All @@ -65,6 +70,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)
}

func TestWithRegexEngine(t *testing.T) {
Expand All @@ -79,13 +85,15 @@ 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)
}

func TestWithExistingOpts(t *testing.T) {
// Create original options with all settings enabled
var testEngine jsonschema.RegexpEngine = nil
original := &ValidationOptions{
RegexEngine: testEngine,
RegexCache: &sync.Map{},
FormatAssertions: true,
ContentAssertions: true,
SecurityValidation: false,
Expand All @@ -95,6 +103,7 @@ func TestWithExistingOpts(t *testing.T) {
opts := NewValidationOptions(WithExistingOpts(original))

assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.NotNil(t, opts.RegexCache)
assert.Equal(t, original.FormatAssertions, opts.FormatAssertions)
assert.Equal(t, original.ContentAssertions, opts.ContentAssertions)
assert.Equal(t, original.SecurityValidation, opts.SecurityValidation)
Expand All @@ -112,6 +121,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)
}

func TestMultipleOptions(t *testing.T) {
Expand All @@ -126,6 +136,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)
}

func TestOptionOverride(t *testing.T) {
Expand All @@ -142,6 +153,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)
}

func TestWithExistingOpts_PartialOverride(t *testing.T) {
Expand All @@ -160,7 +172,8 @@ func TestWithExistingOpts_PartialOverride(t *testing.T) {
WithContentAssertions(), // This should still be true (no change)
)

assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.Nil(t, opts.RegexEngine) // Both should be nil
assert.Nil(t, opts.RegexCache)
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 @@ -189,6 +202,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)
}

func TestMultipleOptionsWithSecurityDisabled(t *testing.T) {
Expand All @@ -202,6 +216,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)
}

func TestWithExistingOpts_SecurityValidationCopied(t *testing.T) {
Expand Down Expand Up @@ -296,6 +311,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)
}

func TestWithExistingOpts_OpenAPIFields(t *testing.T) {
Expand Down Expand Up @@ -329,3 +345,11 @@ func TestWithCustomFormat(t *testing.T) {
assert.Contains(t, opts.Formats, "test-format")
assert.NotNil(t, opts.Formats["test-format"])
}

func TestWithRegexpCache(t *testing.T) {
syncMap := &sync.Map{}

opts := NewValidationOptions(WithRegexCache(syncMap))

assert.NotNil(t, opts.RegexCache)
}
19 changes: 14 additions & 5 deletions helpers/regex_maker.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import (
"strings"
)

var (
baseDefaultPattern = "[^/]*"
DefaultPatternRegex = regexp.MustCompile("^([^/]*)$")
DefaultPatternRegexString = DefaultPatternRegex.String()
)

// GetRegexForPath returns a compiled regular expression for the given path template.
//
// This function takes a path template string `tpl` and generates a regular expression
Expand Down Expand Up @@ -42,9 +48,6 @@ func GetRegexForPath(tpl string) (*regexp.Regexp, error) {
// Backup the original.
template := tpl

// Now let's parse it.
defaultPattern := "[^/]*"

pattern := bytes.NewBufferString("^")
var end int

Expand All @@ -55,7 +58,7 @@ func GetRegexForPath(tpl string) (*regexp.Regexp, error) {
end = idxs[i+1]
parts := strings.SplitN(tpl[idxs[i]+1:end-1], ":", 2)
name := parts[0]
patt := defaultPattern
patt := baseDefaultPattern
if len(parts) == 2 {
patt = parts[1]
}
Expand All @@ -79,8 +82,14 @@ func GetRegexForPath(tpl string) (*regexp.Regexp, error) {

pattern.WriteByte('$')

patternString := pattern.String()

if patternString == DefaultPatternRegexString {
return DefaultPatternRegex, nil
}

// Compile full regexp.
reg, errCompile := regexp.Compile(pattern.String())
reg, errCompile := regexp.Compile(patternString)
if errCompile != nil {
return nil, errCompile
}
Expand Down
16 changes: 16 additions & 0 deletions helpers/regex_maker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package helpers

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetRegexForPath(t *testing.T) {
Expand Down Expand Up @@ -61,6 +63,12 @@ func TestGetRegexForPath(t *testing.T) {
wantErr: false,
wantExpr: "^/entities\\('([0-9]+)'\\)$",
},
{
name: "get default pattern",
tpl: "/{param}",
wantErr: false,
wantExpr: "^/([^/]*)$",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -128,6 +136,14 @@ func TestBraceIndices(t *testing.T) {
}
}

func TestDefaultPatternCompileCache(t *testing.T) {
res, err := GetRegexForPath("{param}")

assert.Nil(t, err)
assert.Equal(t, res, DefaultPatternRegex)
assert.Equal(t, res.String(), DefaultPatternRegexString)
}

func equal(a, b []int) bool {
if len(a) != len(b) {
return false
Expand Down
2 changes: 1 addition & 1 deletion parameters/cookie_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
)

func (v *paramValidator) ValidateCookieParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
if len(errs) > 0 {
return false, errs
}
Expand Down
5 changes: 3 additions & 2 deletions parameters/cookie_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package parameters

import (
"net/http"
"sync"
"testing"

"github.com/pb33f/libopenapi"
Expand Down Expand Up @@ -690,7 +691,7 @@ paths:
request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "2500"}) // too many dude.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model)
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})

valid, errors := v.ValidateCookieParamsWithPathItem(request, path, pv)

Expand Down Expand Up @@ -720,7 +721,7 @@ paths:
request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "2500"}) // too many dude.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model)
path, _, pv := paths.FindPath(request, &m.Model, nil)

valid, errors := v.ValidateCookieParamsWithPathItem(request, path, pv)

Expand Down
2 changes: 1 addition & 1 deletion parameters/header_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

func (v *paramValidator) ValidateHeaderParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
if len(errs) > 0 {
return false, errs
}
Expand Down
5 changes: 3 additions & 2 deletions parameters/header_parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package parameters

import (
"net/http"
"sync"
"testing"

"github.com/pb33f/libopenapi"
Expand Down Expand Up @@ -717,7 +718,7 @@ paths:
request.Header.Set("coffeecups", "1200") // that's a lot of cups dude, we only have one dishwasher.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model)
path, _, pv := paths.FindPath(request, &m.Model, nil)

valid, errors := v.ValidateHeaderParamsWithPathItem(request, path, pv)

Expand Down Expand Up @@ -748,7 +749,7 @@ paths:
request.Header.Set("coffeecups", "1200") // that's a lot of cups dude, we only have one dishwasher.

// preset the path
path, _, pv := paths.FindPath(request, &m.Model)
path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{})

valid, errors := v.ValidateHeaderParamsWithPathItem(request, path, pv)

Expand Down
28 changes: 22 additions & 6 deletions parameters/path_parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

func (v *paramValidator) ValidatePathParams(request *http.Request) (bool, []*errors.ValidationError) {
pathItem, errs, foundPath := paths.FindPath(request, v.document)
pathItem, errs, foundPath := paths.FindPath(request, v.document, v.options.RegexCache)
if len(errs) > 0 {
return false, errs
}
Expand Down Expand Up @@ -57,13 +57,29 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p
continue
}

r, err := helpers.GetRegexForPath(pathSegments[x])
if err != nil {
continue
var rgx *regexp.Regexp

if v.options.RegexCache != nil {
if cachedRegex, found := v.options.RegexCache.Load(pathSegments[x]); found {
rgx = cachedRegex.(*regexp.Regexp)
}
}

if rgx == nil {

r, err := helpers.GetRegexForPath(pathSegments[x])
if err != nil {
continue
}

rgx = r

if v.options.RegexCache != nil {
v.options.RegexCache.Store(pathSegments[x], r)
}
}

re := regexp.MustCompile(r.String())
matches := re.FindStringSubmatch(submittedSegments[x])
matches := rgx.FindStringSubmatch(submittedSegments[x])
matches = matches[1:]

// Check if it is well-formed.
Expand Down
Loading