diff --git a/helpers/parameter_utilities.go b/helpers/parameter_utilities.go index 9111819..6378012 100644 --- a/helpers/parameter_utilities.go +++ b/helpers/parameter_utilities.go @@ -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 @@ -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. diff --git a/helpers/parameter_utilities_test.go b/helpers/parameter_utilities_test.go index 6a450c8..4469457 100644 --- a/helpers/parameter_utilities_test.go +++ b/helpers/parameter_utilities_test.go @@ -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) + }) +} diff --git a/helpers/regex_maker.go b/helpers/regex_maker.go index 3fcbc2e..2842d91 100644 --- a/helpers/regex_maker.go +++ b/helpers/regex_maker.go @@ -1,10 +1,10 @@ package helpers import ( - "bytes" "fmt" "regexp" "strings" + "sync" ) var ( @@ -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 @@ -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. // @@ -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 { @@ -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(')') } @@ -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. diff --git a/helpers/regex_maker_bench_test.go b/helpers/regex_maker_bench_test.go new file mode 100644 index 0000000..5189437 --- /dev/null +++ b/helpers/regex_maker_bench_test.go @@ -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++ + } + }) +} diff --git a/helpers/regex_maker_test.go b/helpers/regex_maker_test.go index 99315d1..fccf720 100644 --- a/helpers/regex_maker_test.go +++ b/helpers/regex_maker_test.go @@ -144,6 +144,63 @@ func TestDefaultPatternCompileCache(t *testing.T) { assert.Equal(t, res.String(), DefaultPatternRegexString) } +func TestGetRegexForPathReturnsCachedRegex(t *testing.T) { + first, errFirst := GetRegexForPath("/cache-check/{id}") + second, errSecond := GetRegexForPath("/cache-check/{id}") + + assert.NoError(t, errFirst) + assert.NoError(t, errSecond) + // the same compiled regex comes back for the same template + assert.True(t, first == second, "expected the cached regex pointer for repeated calls") +} + +func TestGetRegexForPathDoesNotCacheErrors(t *testing.T) { + bad := "/cache-check/{:broken}" + + _, errFirst := GetRegexForPath(bad) + _, errSecond := GetRegexForPath(bad) + + // invalid templates stay uncached: every call reports the error again + assert.Error(t, errFirst) + assert.Error(t, errSecond) + assert.Equal(t, errFirst.Error(), errSecond.Error()) +} + +func TestClearRegexCache(t *testing.T) { + before, errBefore := GetRegexForPath("/cache-clear/{id}") + assert.NoError(t, errBefore) + + ClearRegexCache() + + after, errAfter := GetRegexForPath("/cache-clear/{id}") + assert.NoError(t, errAfter) + // the cleared entry is gone: this call compiled a new regex + assert.True(t, before != after, "expected a fresh regex after ClearRegexCache") +} + +func TestGetBraceIndicesForPath(t *testing.T) { + tpl := "/orders/{id:[0-9]+}/items/{itemId}" + + want, errWant := BraceIndices(tpl) + assert.NoError(t, errWant) + + first, errFirst := GetBraceIndicesForPath(tpl) + second, errSecond := GetBraceIndicesForPath(tpl) + + assert.NoError(t, errFirst) + assert.NoError(t, errSecond) + assert.Equal(t, want, first) + // the same cached indices come back for the same template + assert.True(t, &first[0] == &second[0], "expected the cached indices slice for repeated calls") +} + +func TestGetBraceIndicesForPathInvalidTemplate(t *testing.T) { + idxs, err := GetBraceIndicesForPath("/orders/{id") + + assert.Nil(t, idxs) + assert.Error(t, err) +} + func equal(a, b []int) bool { if len(a) != len(b) { return false @@ -155,3 +212,16 @@ func equal(a, b []int) bool { } return true } + +func TestGetRegexForPathInvalidCustomPattern(t *testing.T) { + // an unterminated character class fails regexp compilation + _, err := GetRegexForPath("/x/{id:[a-z}") + assert.Error(t, err) +} + +func TestGetRegexForPathCapturingGroupRejected(t *testing.T) { + // capturing groups in custom patterns are rejected + _, err := GetRegexForPath("/x/{id:(abc)}") + assert.Error(t, err) + assert.Contains(t, err.Error(), "capture groups") +} diff --git a/parameters/content_parameter.go b/parameters/content_parameter.go index 6133fc5..51cf2bf 100644 --- a/parameters/content_parameter.go +++ b/parameters/content_parameter.go @@ -126,7 +126,7 @@ func pathParameterValues(requestPath, template, target string) []string { return nil } matches := expression.FindStringSubmatch(requestPath) - indices, err := helpers.BraceIndices(template) + indices, err := helpers.GetBraceIndicesForPath(template) if err != nil || len(matches) != len(indices)/2+1 { return nil } diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go index d676a10..acb3606 100644 --- a/parameters/path_parameters.go +++ b/parameters/path_parameters.go @@ -115,7 +115,7 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p matches = matches[1:] // Check if it is well-formed. - idxs, errBraces := helpers.BraceIndices(pathSegments[x]) + idxs, errBraces := helpers.GetBraceIndicesForPath(pathSegments[x]) if errBraces != nil { continue } @@ -442,14 +442,14 @@ func segmentReferencesParam(segment, name string) bool { // nonEmptyPathSegments splits a path on "/" and drops empty segments, so that // leading, trailing, or repeated slashes do not introduce empty entries that // would shift index alignment between submitted and template segments. +// One pass with an iterator: strings.Split would allocate the raw slice and +// copy the non-empty entries into a second slice. func nonEmptyPathSegments(path string) []string { - raw := strings.Split(path, helpers.Slash) - segments := make([]string, 0, len(raw)) - for _, segment := range raw { - if segment == "" { - continue + segments := make([]string, 0, strings.Count(path, helpers.Slash)+1) + for segment := range strings.SplitSeq(path, helpers.Slash) { + if segment != "" { + segments = append(segments, segment) } - segments = append(segments, segment) } return segments } diff --git a/parameters/path_parameters_test.go b/parameters/path_parameters_test.go index 0d3ac51..eb5e576 100644 --- a/parameters/path_parameters_test.go +++ b/parameters/path_parameters_test.go @@ -5,6 +5,7 @@ package parameters import ( "net/http" + "regexp" "sync" "sync/atomic" "testing" @@ -2528,3 +2529,38 @@ paths: assert.Empty(t, validationErrors, tc.mode) } } + +// TestValidatePathParamsWithPathItem_SkipsUnbracedSegment pins the defensive +// behavior when the user-supplied regex cache holds a regex for a segment +// with unbalanced braces: the segment regex matches, but the segment carries +// no brace indices, so validation skips it instead of mis-validating it. +func TestValidatePathParamsWithPathItem_SkipsUnbracedSegment(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/{burgerId}/locate: + parameters: + - in: path + name: burgerId + schema: + type: integer + get: + operationId: locateBurgers` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // a regex stored under a key with unbalanced braces: GetRegexForPath + // cannot produce it, so only a user-populated cache can hit this state. + cache := &sync.Map{} + cache.Store("{oops", regexp.MustCompile(".*")) + + v := NewParameterValidator(&m.Model, config.WithRegexCache(cache)) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/123/locate", nil) + pathItem, _, _ := paths.FindPath(request, &m.Model, nil) + require.NotNil(t, pathItem) + + // the crafted template adds a segment with unbalanced braces; the + // validator must skip that segment and still validate burgerId. + ok, errs := v.ValidatePathParamsWithPathItem(request, pathItem, "/burgers/{burgerId}/{oops") + assert.True(t, ok, "expected the unbraced segment to be skipped without errors: %v", errs) +} diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index eaec8c9..7329d61 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -78,6 +78,11 @@ func GetRenderedSchema(schema *base.Schema, opts *config.ValidationOptions) stri if opts != nil && opts.SchemaCache != nil && schema.GoLow() != nil { hash := schema.GoLow().Hash() if cached, ok := opts.SchemaCache.Load(hash); ok && cached != nil && len(cached.RenderedInline) > 0 { + // ReferenceSchema holds the string form of RenderedInline; return it + // instead of copying the bytes into a new string on every call. + if cached.ReferenceSchema != "" { + return cached.ReferenceSchema + } return string(cached.RenderedInline) } } diff --git a/requests/validate_body.go b/requests/validate_body.go index 5a7aa03..ff15f83 100644 --- a/requests/validate_body.go +++ b/requests/validate_body.go @@ -12,7 +12,6 @@ import ( v3 "github.com/pb33f/libopenapi/datamodel/high/v3" - "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/content" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" @@ -154,14 +153,14 @@ func (v *requestBodyValidator) ValidateRequestBodyWithPathItem(request *http.Req decoded = true validationSucceeded, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{ - Request: request, - Schema: schema, - Version: helpers.VersionToFloat(v.document.Version), - Options: []config.Option{config.WithExistingOpts(v.options)}, - BodyRequired: required, - DecodedValue: decodedValue, - RawBody: requestBody, - ValueDecoded: decoded, + Request: request, + Schema: schema, + Version: helpers.VersionToFloat(v.document.Version), + ValidationOptions: v.options, + BodyRequired: required, + DecodedValue: decodedValue, + RawBody: requestBody, + ValueDecoded: decoded, }) errors.PopulateValidationErrors(validationErrors, request, pathValue) diff --git a/requests/validate_request.go b/requests/validate_request.go index f5d8a9b..05031d5 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -38,6 +38,12 @@ type ValidateRequestSchemaInput struct { DecodedValue any // Optional: A value produced by a registered body decoder RawBody []byte // Optional: Original bytes used for diagnostics with DecodedValue ValueDecoded bool // Distinguishes an explicitly decoded nil from the legacy JSON path + + // ValidationOptions carries pre-built options. When set, the function uses + // it directly and skips NewValidationOptions, which allocates fresh default + // schema caches on every call. The options are treated as read-only and + // must stay valid for the duration of the call. + ValidationOptions *config.ValidationOptions } func setRequestBody(request *http.Request, body []byte) { @@ -53,7 +59,10 @@ func readAndResetRequestBody(request *http.Request) []byte { // If validation fails, it will return a list of validation errors as the second return value. // The schema will be stored and reused from cache if available, otherwise it will be compiled on each call. func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberrors.ValidationError) { - validationOptions := config.NewValidationOptions(input.Options...) + validationOptions := input.ValidationOptions + if validationOptions == nil { + validationOptions = config.NewValidationOptions(input.Options...) + } var validationErrors []*liberrors.ValidationError var renderedSchema, jsonSchema []byte var referenceSchema string diff --git a/responses/validate_body.go b/responses/validate_body.go index d363f2f..a385dd3 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -17,7 +17,6 @@ import ( v3 "github.com/pb33f/libopenapi/datamodel/high/v3" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" - "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/content" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" @@ -107,7 +106,7 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R if isDefault { headerCode = lowv3.DefaultLabel } - if ok, hErrs := ValidateResponseHeaders(request, response, foundResponse.Headers, pathFound, headerCode, config.WithExistingOpts(v.options)); !ok { + if ok, hErrs := validateResponseHeaders(request, response, foundResponse.Headers, pathFound, headerCode, v.options); !ok { validationErrors = append(validationErrors, hErrs...) } } @@ -161,7 +160,7 @@ func (v *responseBodyValidator) checkResponseSchema( } _, bodyErrors := ValidateResponseSchema(&ValidateResponseSchemaInput{ Request: request, Response: validationResponse, Schema: schema, - Version: helpers.VersionToFloat(v.document.Version), Options: []config.Option{config.WithExistingOpts(v.options)}, + Version: helpers.VersionToFloat(v.document.Version), ValidationOptions: v.options, }) return bodyErrors } @@ -209,14 +208,14 @@ func (v *responseBodyValidator) checkResponseSchema( // Validate response schema valid, vErrs := ValidateResponseSchema(&ValidateResponseSchemaInput{ - Request: request, - Response: response, - Schema: schema, - Version: helpers.VersionToFloat(v.document.Version), - Options: []config.Option{config.WithExistingOpts(v.options)}, - DecodedValue: decodedValue, - RawBody: responseBody, - ValueDecoded: decoded, + Request: request, + Response: response, + Schema: schema, + Version: helpers.VersionToFloat(v.document.Version), + ValidationOptions: v.options, + DecodedValue: decodedValue, + RawBody: responseBody, + ValueDecoded: decoded, }) if !valid { diff --git a/responses/validate_headers.go b/responses/validate_headers.go index f12680c..a163f3c 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -32,8 +32,22 @@ func ValidateResponseHeaders( statusCode string, opts ...config.Option, ) (bool, []*errors.ValidationError) { - options := config.NewValidationOptions(opts...) + return validateResponseHeaders(request, response, headers, pathTemplate, statusCode, + config.NewValidationOptions(opts...)) +} +// validateResponseHeaders holds the header validation flow for a response with +// pre-built options. Building options here would allocate fresh default schema +// caches on every call, so callers with built options pass them directly. The +// options are treated as read-only. +func validateResponseHeaders( + request *http.Request, + response *http.Response, + headers *orderedmap.Map[string, *v3.Header], + pathTemplate string, + statusCode string, + options *config.ValidationOptions, +) (bool, []*errors.ValidationError) { // locate headers type headerPair struct { name string diff --git a/responses/validate_response.go b/responses/validate_response.go index 474b0c3..de4c377 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -39,6 +39,12 @@ type ValidateResponseSchemaInput struct { DecodedValue any // Optional: A value produced by a registered body decoder RawBody []byte // Optional: Original bytes used for diagnostics with DecodedValue ValueDecoded bool // Distinguishes an explicitly decoded nil from the legacy JSON path + + // ValidationOptions carries pre-built options. When set, the function uses + // it directly and skips NewValidationOptions, which allocates fresh default + // schema caches on every call. The options are treated as read-only and + // must stay valid for the duration of the call. + ValidationOptions *config.ValidationOptions } // ValidateResponseSchema will validate the response body for a http.Response pointer. The request is used to @@ -48,7 +54,10 @@ type ValidateResponseSchemaInput struct { // This function is used by the ValidateResponseBody function, but can be used independently. // The schema will be compiled from cache if available, otherwise it will be compiled and cached. func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberrors.ValidationError) { - validationOptions := config.NewValidationOptions(input.Options...) + validationOptions := input.ValidationOptions + if validationOptions == nil { + validationOptions = config.NewValidationOptions(input.Options...) + } var validationErrors []*liberrors.ValidationError var renderedSchema []byte var referenceSchema string diff --git a/validator_request_bench_test.go b/validator_request_bench_test.go new file mode 100644 index 0000000..303b9ee --- /dev/null +++ b/validator_request_bench_test.go @@ -0,0 +1,273 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package validator + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/pb33f/libopenapi" +) + +// requestBenchSpec holds the happy-path shapes a gateway validates: a single +// path parameter, several parameters of mixed locations, a JSON body, a +// query-heavy search operation, and an operation with enum parameters. +const requestBenchSpec = `openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: ok + headers: + X-Trace-Id: + schema: + type: string + /orders/{orderId}/items/{itemId}: + get: + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + minimum: 1 + - name: itemId + in: path + required: true + schema: + type: string + pattern: '^[A-Z]{2}[0-9]{4}$' + - name: status + in: query + schema: + type: string + enum: [open, closed] + - name: X-Request-Id + in: header + required: true + schema: + type: string + format: uuid + responses: + '200': + description: ok + /burgers: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + '200': + description: ok + /search: + get: + parameters: + - name: q + in: query + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + - name: status + in: query + schema: + type: string + enum: [open, closed, all] + - name: sort + in: query + schema: + type: string + - name: X-Channel + in: header + schema: + type: string + enum: [web, mobile] + responses: + '200': + description: ok +` + +func prepareRequestBenchValidator(b *testing.B) Validator { + b.Helper() + + doc, err := libopenapi.NewDocument([]byte(requestBenchSpec)) + if err != nil { + b.Fatalf("error creating document: %v", err) + } + + v, errs := NewValidator(doc) + if len(errs) > 0 { + b.Fatalf("errors building validator: %v", errs) + } + b.Cleanup(v.Release) + return v +} + +func benchRequest(b *testing.B, method, target string, body io.Reader) *http.Request { + b.Helper() + + req, err := http.NewRequest(method, target, body) + if err != nil { + b.Fatalf("error building request: %v", err) + } + return req +} + +// BenchmarkValidateHttpRequestSync measures one valid GET with one uuid path +// parameter through the full synchronous entry point: routing plus path, +// header, cookie and query parameter validation. +func BenchmarkValidateHttpRequestSync(b *testing.B) { + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncPathQueryHeader measures one valid GET that +// exercises routing, two path parameters, one query parameter and one header +// parameter. +func BenchmarkValidateHttpRequestSyncPathQueryHeader(b *testing.B) { + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/orders/12/items/AB1234?status=closed", nil) + req.Header.Set("X-Request-Id", "3f2504e0-4f89-11d3-9a0c-0305e82c3301") + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpResponse measures one valid 200 response for the +// /users/{id} operation: routing, response headers and response body +// validation. +func BenchmarkValidateHttpResponse(b *testing.B) { + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.ReportAllocs() + + for b.Loop() { + resp := &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"X-Trace-Id": []string{"3f2504e0-4f89-11d3-9a0c-0305e82c3301"}}, + Body: http.NoBody, + } + + valid, validationErrors := v.ValidateHttpResponse(req, resp) + if !valid { + b.Fatalf("response should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncJSONBody measures one valid POST with a JSON +// body. The request is rebuilt per iteration, because validation consumes the +// body reader; the repo benchmark pattern does the same. +func BenchmarkValidateHttpRequestSyncJSONBody(b *testing.B) { + v := prepareRequestBenchValidator(b) + body := []byte(`{"name":"cheeseburger"}`) + + b.ReportAllocs() + + for b.Loop() { + req := benchRequest(b, http.MethodPost, "/burgers", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncQuery measures one valid GET with four query +// parameters and an enum header parameter, the query-heavy gateway shape. +func BenchmarkValidateHttpRequestSyncQuery(b *testing.B) { + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/search?q=burger&limit=10&status=closed&sort=price", nil) + req.Header.Set("X-Channel", "web") + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncInvalid measures one invalid GET: the +// orderId path parameter fails its integer schema. Validation reports errors, +// so this shape carries the error-construction cost by design. +func BenchmarkValidateHttpRequestSyncInvalidParam(b *testing.B) { + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/orders/not-a-number/items/AB1234?status=closed", nil) + req.Header.Set("X-Request-Id", "3f2504e0-4f89-11d3-9a0c-0305e82c3301") + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if valid || len(validationErrors) == 0 { + b.Fatalf("request should fail validation with errors, got valid=%v errors=%d", valid, len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncParallel runs the plain GET shape from many +// goroutines: a gateway shares the validator and its caches across requests. +func BenchmarkValidateHttpRequestSyncParallel(b *testing.B) { + v := prepareRequestBenchValidator(b) + + b.ReportAllocs() + + req := benchRequest(b, http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } + }) +} diff --git a/validator_scenario_bench_test.go b/validator_scenario_bench_test.go new file mode 100644 index 0000000..8ddfcd0 --- /dev/null +++ b/validator_scenario_bench_test.go @@ -0,0 +1,249 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package validator + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + + "github.com/pb33f/libopenapi-validator/config" +) + +// The scenario specs below cover the gateway shapes the main benchmark file +// does not: format assertions, strict server matching, urlencoded bodies, +// JSON response bodies, and a large routing table. + +const formatAssertionsSpec = `openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: ok +` + +const strictServerSpec = `openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +servers: + - url: https://things.com/api +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: ok +` + +const urlEncodedSpec = `openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +paths: + /burgers: + post: + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [name] + properties: + name: + type: string + size: + type: string + responses: + '200': + description: ok +` + +const responseJSONSpec = `openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +paths: + /reports/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + required: [ok] + properties: + ok: + type: boolean +` + +// largeRouteSpec builds a document with count templated path items. +func largeRouteSpec(count int) string { + var spec strings.Builder + spec.WriteString(`openapi: 3.1.0 +info: + title: bench + version: 1.0.0 +paths: +`) + for i := 0; i < count; i++ { + fmt.Fprintf(&spec, " /resource-%d/{id}:\n get:\n parameters:\n - name: id\n in: path\n required: true\n schema:\n type: string\n format: uuid\n responses:\n '200':\n description: ok\n", i) + } + return spec.String() +} + +func nopBody(body []byte) io.ReadCloser { + return io.NopCloser(bytes.NewReader(body)) +} + +// prepareScenarioValidator builds a validator for the given spec and options +// and fails the benchmark on build errors. +func prepareScenarioValidator(b *testing.B, spec string, opts ...config.Option) Validator { + b.Helper() + + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + b.Fatalf("error creating document: %v", err) + } + + v, errs := NewValidator(doc, opts...) + if len(errs) > 0 { + b.Fatalf("errors building validator: %v", errs) + } + b.Cleanup(v.Release) + return v +} + +// BenchmarkValidateHttpRequestSyncFormatAssertions measures one valid GET with +// format assertions enabled, so the uuid format machinery runs on every call. +func BenchmarkValidateHttpRequestSyncFormatAssertions(b *testing.B) { + v := prepareScenarioValidator(b, formatAssertionsSpec, config.WithFormatAssertions()) + + req := benchRequest(b, http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncStrictServer measures one valid GET with +// strict server matching enabled: scheme, host and base path take part in +// routing. +func BenchmarkValidateHttpRequestSyncStrictServer(b *testing.B) { + v := prepareScenarioValidator(b, strictServerSpec, config.WithStrictServerMatching()) + + req := benchRequest(b, http.MethodGet, "https://things.com/api/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncURLEncoded measures one valid POST with a +// form-urlencoded body. The request is rebuilt per iteration, because +// validation consumes the body reader. +func BenchmarkValidateHttpRequestSyncURLEncoded(b *testing.B) { + v := prepareScenarioValidator(b, urlEncodedSpec, config.WithURLEncodedBodyValidation()) + + body := []byte("name=cheeseburger&size=large") + + b.ReportAllocs() + + for b.Loop() { + req := benchRequest(b, http.MethodPost, "/burgers", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpResponseJSONBody measures one valid 200 response with a +// JSON body. The response is rebuilt per iteration, because validation +// consumes the body reader. +func BenchmarkValidateHttpResponseJSONBody(b *testing.B) { + v := prepareScenarioValidator(b, responseJSONSpec) + + req := benchRequest(b, http.MethodGet, "/reports/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + body := []byte(`{"ok":true}`) + + b.ReportAllocs() + + for b.Loop() { + resp := &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: nopBody(body), + } + + valid, validationErrors := v.ValidateHttpResponse(req, resp) + if !valid { + b.Fatalf("response should validate, got %d errors", len(validationErrors)) + } + } +} + +// BenchmarkValidateHttpRequestSyncLargeRoutes measures one valid request +// against a document with 200 path items, where the routing table and the +// regex fallback show their cost. +func BenchmarkValidateHttpRequestSyncLargeRoutes(b *testing.B) { + v := prepareScenarioValidator(b, largeRouteSpec(200)) + + req := benchRequest(b, http.MethodGet, "/resource-150/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +}