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
36 changes: 26 additions & 10 deletions helpers/parameter_utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ type QueryParam struct {

// ExtractRawQueryValues parses a URL's RawQuery into a map of decoded key to raw
// (still percent-encoded) values, index-aligned per key with request.URL.Query().
// A query without parameters returns nil.
func ExtractRawQueryValues(rawQuery string) map[string][]string {
if rawQuery == "" {
return nil
}
rawValues := make(map[string][]string)
for rawQuery != "" {
var segment string
Expand All @@ -53,42 +57,54 @@ func ExtractRawQueryValues(rawQuery string) map[string][]string {
// ExtractParamsForOperation will extract the parameters for the operation based on the request method.
// Both the path level params and the method level params will be returned.
func ExtractParamsForOperation(request *http.Request, item *v3.PathItem) []*v3.Parameter {
params := item.Parameters
var operationParams []*v3.Parameter
switch request.Method {
case http.MethodGet:
if item.Get != nil {
params = append(params, item.Get.Parameters...)
operationParams = item.Get.Parameters
}
case http.MethodPost:
if item.Post != nil {
params = append(params, item.Post.Parameters...)
operationParams = item.Post.Parameters
}
case http.MethodPut:
if item.Put != nil {
params = append(params, item.Put.Parameters...)
operationParams = item.Put.Parameters
}
case http.MethodDelete:
if item.Delete != nil {
params = append(params, item.Delete.Parameters...)
operationParams = item.Delete.Parameters
}
case http.MethodOptions:
if item.Options != nil {
params = append(params, item.Options.Parameters...)
operationParams = item.Options.Parameters
}
case http.MethodHead:
if item.Head != nil {
params = append(params, item.Head.Parameters...)
operationParams = item.Head.Parameters
}
case http.MethodPatch:
if item.Patch != nil {
params = append(params, item.Patch.Parameters...)
operationParams = item.Patch.Parameters
}
case http.MethodTrace:
if item.Trace != nil {
params = append(params, item.Trace.Parameters...)
operationParams = item.Trace.Parameters
}
}
return params

// Return the model slices untouched when one side is empty: appending to
// item.Parameters can write into the model backing array, and the model
// stays shared across validators and requests.
if len(operationParams) == 0 {
return item.Parameters
}
if len(item.Parameters) == 0 {
return operationParams
}
merged := make([]*v3.Parameter, 0, len(item.Parameters)+len(operationParams))
merged = append(merged, item.Parameters...)
return append(merged, operationParams...)
}

// ExtractSecurityForOperation will extract the security requirements for the operation based on the request method.
Expand Down
42 changes: 42 additions & 0 deletions helpers/parameter_utilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1430,3 +1430,45 @@ func TestExtractRawQueryValues(t *testing.T) {
"novalue": {""},
}, raw)
}

func TestExtractRawQueryValuesEmptyQuery(t *testing.T) {
require.Nil(t, ExtractRawQueryValues(""))
}

func TestExtractParamsForOperationOneSideEmpty(t *testing.T) {
operationOnly := &v3.Parameter{Name: "id", In: Path}

t.Run("no path level parameters returns operation slice untouched", func(t *testing.T) {
item := &v3.PathItem{Get: &v3.Operation{Parameters: []*v3.Parameter{operationOnly}}}
got := ExtractParamsForOperation(&http.Request{Method: http.MethodGet}, item)
require.Equal(t, []*v3.Parameter{operationOnly}, got)
})

t.Run("no operation parameters returns path slice untouched", func(t *testing.T) {
item := &v3.PathItem{Parameters: []*v3.Parameter{operationOnly}, Get: &v3.Operation{}}
got := ExtractParamsForOperation(&http.Request{Method: http.MethodGet}, item)
require.Equal(t, []*v3.Parameter{operationOnly}, got)
})

t.Run("both sides merge without writing into the model slice", func(t *testing.T) {
pathLevel := &v3.Parameter{Name: "shared", In: Path}
operationLevel := &v3.Parameter{Name: "id", In: Path}
item := &v3.PathItem{
Parameters: []*v3.Parameter{pathLevel},
Get: &v3.Operation{Parameters: []*v3.Parameter{operationLevel}},
}
before := append([]*v3.Parameter(nil), item.Parameters...)

got := ExtractParamsForOperation(&http.Request{Method: http.MethodGet}, item)

require.Equal(t, []*v3.Parameter{pathLevel, operationLevel}, got)
// the model backing array stays untouched
require.Equal(t, before, item.Parameters)
})

t.Run("no operation parameters returns path slice untouched", func(t *testing.T) {
item := &v3.PathItem{Parameters: []*v3.Parameter{operationOnly}, Get: &v3.Operation{}}
got := ExtractParamsForOperation(&http.Request{Method: http.MethodGet}, item)
require.Equal(t, []*v3.Parameter{operationOnly}, got)
})
}
97 changes: 85 additions & 12 deletions helpers/regex_maker.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package helpers

import (
"bytes"
"fmt"
"regexp"
"strings"
"sync"
)

var (
Expand All @@ -13,6 +13,26 @@ var (
DefaultPatternRegexString = DefaultPatternRegex.String()
)

// regexCache stores compiled regular expressions by path template.
//
// Request validation calls GetRegexForPath for each path template on every
// request. The key set is the route table of the document, which is small and
// stable for the life of the process, so the cache holds one entry per unique
// template. Each entry holds a few KB (key string plus compiled regex), so a
// normal route table costs a few MB at most. Callers that pass an unbounded
// number of unique templates grow the cache without limit.
//
// ClearRegexCache empties the cache. Call it between document lifecycles in
// long-running processes to bound memory.
var regexCache sync.Map // map[string]*regexp.Regexp

// ClearRegexCache removes every cached compiled regex. The next call for a
// template compiles it again. Safe to call while other goroutines call
// GetRegexForPath.
func ClearRegexCache() {
regexCache.Clear()
}

// 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 All @@ -26,6 +46,11 @@ var (
// The function ensures that the template is well-formed, with balanced and properly
// nested braces. If the template is invalid, an error is returned.
//
// Compiled regexes are cached by template string. The first call for a template
// compiles it; later calls return the cached regex. The cache is safe for
// concurrent use. Invalid templates are not cached. ClearRegexCache empties
// the cache.
//
// Parameters:
// - tpl: The path template string to convert into a regular expression.
//
Expand All @@ -39,16 +64,64 @@ var (
// // regex: ^/orders/([0-9]+)/items/([^/]+)$
// // err: nil
func GetRegexForPath(tpl string) (*regexp.Regexp, error) {
// Return the cached regex for this template when one exists.
if cached, ok := regexCache.Load(tpl); ok {
return cached.(pathRegexEntry).regex, nil
}

entry, errCompile := compilePathRegexEntry(tpl)
if errCompile != nil {
return nil, errCompile
}

// A concurrent call for the same template may have stored its entry first.
// LoadOrStore returns the stored winner; this call discards its own copy.
actual, _ := regexCache.LoadOrStore(tpl, entry)
return actual.(pathRegexEntry).regex, nil
}

// GetBraceIndicesForPath returns the brace indices of the given path template,
// the same values BraceIndices computes. The indices come from the compile
// cache, so repeated calls for one template do not re-scan it. Callers must
// treat the returned slice as read-only. An invalid template returns an error.
func GetBraceIndicesForPath(tpl string) ([]int, error) {
if cached, ok := regexCache.Load(tpl); ok {
return cached.(pathRegexEntry).braceIdxs, nil
}

entry, errCompile := compilePathRegexEntry(tpl)
if errCompile != nil {
return nil, errCompile
}

actual, _ := regexCache.LoadOrStore(tpl, entry)
return actual.(pathRegexEntry).braceIdxs, nil
}

// pathRegexEntry holds one compile result for a path template: the compiled
// regex and the brace indices of the template placeholders.
type pathRegexEntry struct {
regex *regexp.Regexp
braceIdxs []int
}

// compilePathRegexEntry builds and compiles a regular expression for the given
// path template. It holds the original, uncached implementation.
func compilePathRegexEntry(tpl string) (pathRegexEntry, error) {
// Check if it is well-formed.
idxs, errBraces := BraceIndices(tpl)
if errBraces != nil {
return nil, errBraces
return pathRegexEntry{}, errBraces
}

// Backup the original.
template := tpl

pattern := bytes.NewBufferString("^")
// bytes.Buffer and strings.Builder writes cannot fail: the buffer grows
// on demand and panics on overflow instead of returning an error, so the
// pattern is assembled with plain writes and no error checks.
var pattern strings.Builder
pattern.WriteString("^")
var end int

for i := 0; i < len(idxs); i += 2 {
Expand All @@ -65,14 +138,14 @@ func GetRegexForPath(tpl string) (*regexp.Regexp, error) {

// Name or pattern can't be empty.
if name == "" || patt == "" {
return nil, fmt.Errorf("missing name or pattern in %q", tpl[idxs[i]:end])
return pathRegexEntry{}, fmt.Errorf("missing name or pattern in %q", tpl[idxs[i]:end])
}

// Build the regexp pattern.
_, err := fmt.Fprintf(pattern, "%s(%s)", regexp.QuoteMeta(raw), patt)
if err != nil {
return nil, err
}
pattern.WriteString(regexp.QuoteMeta(raw))
pattern.WriteByte('(')
pattern.WriteString(patt)
pattern.WriteByte(')')

}

Expand All @@ -85,22 +158,22 @@ func GetRegexForPath(tpl string) (*regexp.Regexp, error) {
patternString := pattern.String()

if patternString == DefaultPatternRegexString {
return DefaultPatternRegex, nil
return pathRegexEntry{regex: DefaultPatternRegex, braceIdxs: idxs}, nil
}

// Compile full regexp.
reg, errCompile := regexp.Compile(patternString)
if errCompile != nil {
return nil, errCompile
return pathRegexEntry{}, errCompile
}

// Check for capturing groups which used to work in older versions
if reg.NumSubexp() != len(idxs)/2 {
return nil, fmt.Errorf("route %s contains capture groups in its regexp. Only non-capturing groups are accepted: e.g. (?:pattern) instead of (pattern)", template)
return pathRegexEntry{}, fmt.Errorf("route %s contains capture groups in its regexp. Only non-capturing groups are accepted: e.g. (?:pattern) instead of (pattern)", template)
}

// Done!
return reg, nil
return pathRegexEntry{regex: reg, braceIdxs: idxs}, nil
}

// BraceIndices returns the indices of the opening and closing braces in a string.
Expand Down
82 changes: 82 additions & 0 deletions helpers/regex_maker_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT

package helpers

import (
"fmt"
"sync/atomic"
"testing"
)

// benchTemplates covers the path template shapes this library sees in real specs.
var benchTemplates = []struct {
name string
tpl string
}{
{"no_params", "/health/live"},
{"one_default_param", "/users/{id}"},
{"one_custom_pattern", "/users/{id:[0-9a-f-]{36}}"},
{"many_params", "/orgs/{org}/repos/{repo}/pulls/{pull}/files/{file}"},
{"odata_style", "/entities('{id}')"},
}

// BenchmarkGetRegexForPath measures a repeated call for the same template.
// Without a cache this recompiles the regex every call; with the cache it
// measures a cache hit.
func BenchmarkGetRegexForPath(b *testing.B) {
for _, bc := range benchTemplates {
b.Run(bc.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
r, err := GetRegexForPath(bc.tpl)
if err != nil {
b.Fatalf("unexpected error for %q: %v", bc.tpl, err)
}
if r == nil {
b.Fatalf("nil regex for %q", bc.tpl)
}
}
})
}
}

// coldCounter hands every cold-benchmark iteration a template that no earlier
// iteration in this process used, so each call is a cache miss.
var coldCounter atomic.Int64

// BenchmarkGetRegexForPathCold measures a cache miss on every iteration: the
// template counter never repeats inside the process, so each call compiles.
// It shows the cost the cache removes from the repeat path, and the fixed
// overhead that a miss keeps.
func BenchmarkGetRegexForPathCold(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
tpl := fmt.Sprintf("/users/%d/{id}", coldCounter.Add(1))
if _, err := GetRegexForPath(tpl); err != nil {
b.Fatalf("unexpected error for %q: %v", tpl, err)
}
}
}

// BenchmarkGetRegexForPathParallel hammers a small, stable template set from
// many goroutines, which is what a request-validation hot path looks like.
func BenchmarkGetRegexForPathParallel(b *testing.B) {
templates := []string{
"/users/{id}",
"/users/{id:[0-9a-f-]{36}}",
"/orgs/{org}/repos/{repo}",
"/health/live",
}
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
tpl := templates[i%len(templates)]
if _, err := GetRegexForPath(tpl); err != nil {
b.Fatalf("unexpected error for %q: %v", tpl, err)
}
i++
}
})
}
Loading
Loading