From 01df35342361cd5af0554e150d36852d86d11ef3 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Tue, 3 Nov 2026 16:23:01 +0100 Subject: [PATCH 01/13] perf: cache compiled path regexes in GetRegexForPath Request validation calls GetRegexForPath on every request, which rebuilt and recompiled a regex for the same path template each time. Cache compiled regexes by template in a package-level sync.Map: the key set is the route table of the document, small and stable for the process lifetime. Invalid templates stay uncached. Measured with BenchmarkValidateHttpRequestSync (one valid GET through ValidateHttpRequestSync, spec with one templated path, n=10): - sec/op: 9.82us -> 4.09us (-58%) - B/op: 5.67Ki -> 2.03Ki (-64%) - allocs/op: 97 -> 44 (-55%) A warm GetRegexForPath call drops from 3.3-11.5us and 63-204 allocs to ~20ns and 0 allocs. A cold call keeps its compile cost plus one cache entry. The config.RegexCache option keeps working at its call sites; this cache makes the default path fast with no configuration. Follows the libopenapi cache convention for content-keyed caches: ClearRegexCache empties the cache between document lifecycles in long-running processes, the same contract as utils.ClearJSONPathCache. An entry holds a few KB (measured 4.3 KB: key string plus compiled regex), so a normal route table costs a few MB at most. --- helpers/regex_maker.go | 45 +++++++++++++++++ helpers/regex_maker_bench_test.go | 82 +++++++++++++++++++++++++++++++ helpers/regex_maker_test.go | 34 +++++++++++++ validator_request_bench_test.go | 69 ++++++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 helpers/regex_maker_bench_test.go create mode 100644 validator_request_bench_test.go diff --git a/helpers/regex_maker.go b/helpers/regex_maker.go index 3fcbc2ea..2377ddae 100644 --- a/helpers/regex_maker.go +++ b/helpers/regex_maker.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "sync" ) var ( @@ -13,6 +14,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 +47,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,6 +65,25 @@ 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.(*regexp.Regexp), nil + } + + reg, errCompile := compileRegexForPath(tpl) + if errCompile != nil { + return nil, errCompile + } + + // A concurrent call for the same template may have stored its regex first. + // LoadOrStore returns the stored winner; this call discards its own copy. + actual, _ := regexCache.LoadOrStore(tpl, reg) + return actual.(*regexp.Regexp), nil +} + +// compileRegexForPath builds and compiles a regular expression for the given +// path template. It holds the original, uncached implementation. +func compileRegexForPath(tpl string) (*regexp.Regexp, error) { // Check if it is well-formed. idxs, errBraces := BraceIndices(tpl) if errBraces != nil { diff --git a/helpers/regex_maker_bench_test.go b/helpers/regex_maker_bench_test.go new file mode 100644 index 00000000..5189437f --- /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 99315d11..b38c9621 100644 --- a/helpers/regex_maker_test.go +++ b/helpers/regex_maker_test.go @@ -144,6 +144,40 @@ 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 equal(a, b []int) bool { if len(a) != len(b) { return false diff --git a/validator_request_bench_test.go b/validator_request_bench_test.go new file mode 100644 index 00000000..5406b1da --- /dev/null +++ b/validator_request_bench_test.go @@ -0,0 +1,69 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package validator + +import ( + "net/http" + "testing" + + "github.com/pb33f/libopenapi" +) + +// requestBenchSpec is a minimal spec with one templated path and one uuid path +// parameter, the shape that gateway request validation sees on every call. +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 +` + +func prepareRequestBenchValidator(b *testing.B) (Validator, *http.Request) { + 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) + } + + req, err := http.NewRequest(http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + if err != nil { + b.Fatalf("error building request: %v", err) + } + return v, req +} + +// BenchmarkValidateHttpRequestSync measures one valid request through the full +// synchronous entry point: routing plus path, header, cookie and query +// parameter validation. +func BenchmarkValidateHttpRequestSync(b *testing.B) { + v, req := prepareRequestBenchValidator(b) + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + valid, validationErrors := v.ValidateHttpRequestSync(req) + if !valid { + b.Fatalf("request should validate, got %d errors", len(validationErrors)) + } + } +} From 55018154f3097587bc18f77ceee21ce74f0fda7e Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Tue, 3 Nov 2026 17:28:22 +0100 Subject: [PATCH 02/13] perf: cut happy-path allocations in request validation Two changes for the valid-request path, each measured with the request benchmark suite (one param, three params with query and header, JSON body; n=10, back-to-back runs): - Cache brace indices with the compiled regex. New GetBraceIndicesForPath returns the cached indices for a template, so path parameter validation stops re-scanning the template on every request. allocs/op: 44 -> 43 (one param), 108 -> 104 (three params). - ExtractParamsForOperation no longer appends operation parameters onto item.Parameters, which could write into the shared model backing array. One side empty returns the model slice untouched; both sides non-empty merges with an exact-size slice. allocs/op: 43 -> 39, B/op down 1.6-2.4%. Time deltas sit inside machine noise on this hardware; allocation counts are exact. The next happy-path lever is GetRenderedSchema, which re-renders parameter schemas on every request when the schema cache is disabled and accounts for about a third of the remaining allocations. --- helpers/parameter_utilities.go | 32 +++++++--- helpers/regex_maker.go | 53 +++++++++++---- helpers/regex_maker_test.go | 23 +++++++ parameters/content_parameter.go | 2 +- parameters/path_parameters.go | 2 +- validator_request_bench_test.go | 110 +++++++++++++++++++++++++++++--- 6 files changed, 186 insertions(+), 36 deletions(-) diff --git a/helpers/parameter_utilities.go b/helpers/parameter_utilities.go index 91118194..81833192 100644 --- a/helpers/parameter_utilities.go +++ b/helpers/parameter_utilities.go @@ -53,42 +53,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/regex_maker.go b/helpers/regex_maker.go index 2377ddae..09bfa2e5 100644 --- a/helpers/regex_maker.go +++ b/helpers/regex_maker.go @@ -67,27 +67,52 @@ func ClearRegexCache() { 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.(*regexp.Regexp), nil + return cached.(pathRegexEntry).regex, nil } - reg, errCompile := compileRegexForPath(tpl) + entry, errCompile := compilePathRegexEntry(tpl) if errCompile != nil { return nil, errCompile } - // A concurrent call for the same template may have stored its regex first. + // 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, reg) - return actual.(*regexp.Regexp), nil + actual, _ := regexCache.LoadOrStore(tpl, entry) + return actual.(pathRegexEntry).regex, nil } -// compileRegexForPath builds and compiles a regular expression for the given +// 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 compileRegexForPath(tpl string) (*regexp.Regexp, error) { +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. @@ -110,13 +135,13 @@ func compileRegexForPath(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 + return pathRegexEntry{}, err } } @@ -130,22 +155,22 @@ func compileRegexForPath(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_test.go b/helpers/regex_maker_test.go index b38c9621..dcdd396b 100644 --- a/helpers/regex_maker_test.go +++ b/helpers/regex_maker_test.go @@ -178,6 +178,29 @@ func TestClearRegexCache(t *testing.T) { 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 diff --git a/parameters/content_parameter.go b/parameters/content_parameter.go index 6133fc5e..51cf2bf3 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 d676a107..d6235172 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 } diff --git a/validator_request_bench_test.go b/validator_request_bench_test.go index 5406b1da..2128757e 100644 --- a/validator_request_bench_test.go +++ b/validator_request_bench_test.go @@ -4,14 +4,16 @@ package validator import ( + "bytes" + "io" "net/http" "testing" "github.com/pb33f/libopenapi" ) -// requestBenchSpec is a minimal spec with one templated path and one uuid path -// parameter, the shape that gateway request validation sees on every call. +// requestBenchSpec holds the happy-path shapes a gateway validates: a single +// path parameter, several parameters of mixed locations, and a JSON body. const requestBenchSpec = `openapi: 3.1.0 info: title: bench @@ -29,9 +31,53 @@ paths: responses: '200': description: ok + /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 ` -func prepareRequestBenchValidator(b *testing.B) (Validator, *http.Request) { +func prepareRequestBenchValidator(b *testing.B) Validator { b.Helper() doc, err := libopenapi.NewDocument([]byte(requestBenchSpec)) @@ -43,21 +89,27 @@ func prepareRequestBenchValidator(b *testing.B) (Validator, *http.Request) { 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(http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) + req, err := http.NewRequest(method, target, body) if err != nil { b.Fatalf("error building request: %v", err) } - return v, req + return req } -// BenchmarkValidateHttpRequestSync measures one valid request through the full -// synchronous entry point: routing plus path, header, cookie and query -// parameter validation. +// 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, req := prepareRequestBenchValidator(b) + v := prepareRequestBenchValidator(b) + req := benchRequest(b, http.MethodGet, "/users/3f2504e0-4f89-11d3-9a0c-0305e82c3301", nil) - b.ResetTimer() b.ReportAllocs() for b.Loop() { @@ -67,3 +119,41 @@ func BenchmarkValidateHttpRequestSync(b *testing.B) { } } } + +// 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)) + } + } +} + +// 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)) + } + } +} From 5f5262e1679ce9770d93703f7e4d430da26808af Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Tue, 3 Nov 2026 17:45:06 +0100 Subject: [PATCH 03/13] perf: reuse built validation options in schema validation ValidateRequestSchema and ValidateResponseSchema called config.NewValidationOptions on every validation, which builds a full default options object first: two fresh schema caches, a content registry and decoder slices. The internal callers then overwrote the caches through WithExistingOpts and dropped the fresh ones, so every request allocated options and caches that it never used. Add an optional ValidationOptions field to both input structs. When set, the function uses it directly and skips building defaults. The options are treated as read-only during validation. The internal body validators now pass their built options. Measured with BenchmarkValidateHttpRequestSyncJSONBody (one valid POST with a JSON body, n=10, back-to-back runs): - sec/op: 8.64us -> 7.29us (-15.6%) - B/op: 5.75Ki -> 5.21Ki (-9.4%) - allocs/op: 75 -> 66 (-12%) The two no-body request shapes are unchanged, as expected: they never enter these functions. ValidateResponseHeaders still builds options per call and stays open for a follow-up. --- requests/validate_body.go | 17 ++++++++--------- requests/validate_request.go | 11 ++++++++++- responses/validate_body.go | 18 +++++++++--------- responses/validate_response.go | 11 ++++++++++- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/requests/validate_body.go b/requests/validate_body.go index 5a7aa031..ff15f83a 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 f5d8a9b7..05031d58 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 d363f2f0..67fd3f2e 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -161,7 +161,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 +209,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_response.go b/responses/validate_response.go index 474b0c3f..de4c377d 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 From a6a047d79113ae96d26b625339ea551f09fe7bf1 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Tue, 3 Nov 2026 18:07:40 +0100 Subject: [PATCH 04/13] perf: split path segments in one pass nonEmptyPathSegments ran strings.Split and then copied the non-empty segments into a second slice: two allocations per call, and the validator calls it for the template and the request path on every request. Walk the path once with strings.SplitSeq and write non-empty segments straight into a slice sized by the separator count. Measured with the request benchmark suite (n=10, back-to-back runs): - ValidateHttpRequestSync: sec/op 4.69us -> 4.31us (-8.0%), allocs/op 39 -> 37 - allocs/op across all shapes: -2 to -3 per request The candidate that hoisted the per-segment regex out of the parameter loop was measured in the same round and rejected: with one path parameter nothing is deduped and the three precompute slices cost more than they save (+9% sec/op on the plain shape). --- parameters/path_parameters.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/parameters/path_parameters.go b/parameters/path_parameters.go index d6235172..acb36064 100644 --- a/parameters/path_parameters.go +++ b/parameters/path_parameters.go @@ -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 } From ce3ba8eb2e2f1f45e300ad3970d92be6aeba1e5b Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Tue, 3 Nov 2026 18:16:45 +0100 Subject: [PATCH 05/13] test: add response validation benchmark BenchmarkValidateHttpResponse measures one valid 200 response with a response header: routing, response header validation and response body validation. The benchmark suite now covers the four gateway shapes: plain GET, parameter-heavy GET, POST with JSON body, and response validation. --- validator_request_bench_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/validator_request_bench_test.go b/validator_request_bench_test.go index 2128757e..60d35dcc 100644 --- a/validator_request_bench_test.go +++ b/validator_request_bench_test.go @@ -31,6 +31,10 @@ paths: responses: '200': description: ok + headers: + X-Trace-Id: + schema: + type: string /orders/{orderId}/items/{itemId}: get: parameters: @@ -138,6 +142,30 @@ func BenchmarkValidateHttpRequestSyncPathQueryHeader(b *testing.B) { } } +// 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. From 71259a35eae0362d77466234fbdc9dce2dadd04d Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 10:26:24 +0100 Subject: [PATCH 06/13] perf: skip query map when the query is empty ExtractRawQueryValues allocated its map on every call, even for requests without a query string. Return nil instead; callers only read the map, and nil map reads are safe. Measured with BenchmarkValidateHttpRequestSync (no query params, n=10, back-to-back runs): allocs/op 37 -> 36, B/op -2.5%. --- helpers/parameter_utilities.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helpers/parameter_utilities.go b/helpers/parameter_utilities.go index 81833192..63780123 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 From ff19efcc832a317873fbce8701fe935d1511a20b Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 10:26:25 +0100 Subject: [PATCH 07/13] perf: return the cached rendered schema string GetRenderedSchema copied the cached rendered schema into a new string on every call, even on a cache hit, because the entry stores the rendered schema as bytes. The entry also carries ReferenceSchema, the string form of the same bytes; return it when present. Measured with BenchmarkValidateHttpRequestSyncPathQueryHeader (n=10, back-to-back runs): allocs/op 98 -> 94, sec/op -3.1%. --- parameters/validate_parameter.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index eaec8c93..7329d611 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) } } From b941576dcd49f1fa47229a6de4626e732bde4bd8 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 10:26:25 +0100 Subject: [PATCH 08/13] perf: reuse built options in response header validation ValidateResponseHeaders built default options on every call, which allocates two fresh schema caches and a content registry that the caller immediately overwrites with shared options. Split the flow into an unexported core that takes pre-built options; the exported function keeps its signature and builds options for direct callers. The internal body validator now passes its built options. Measured with BenchmarkValidateHttpResponse (one valid 200 response with a response header, n=10, back-to-back runs): - sec/op: 4.46us -> 4.05us (-9.3%) - B/op: 2.72Ki -> 2.20Ki (-19%) - allocs/op: 40 -> 33 (-17.5%) --- responses/validate_body.go | 3 +-- responses/validate_headers.go | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/responses/validate_body.go b/responses/validate_body.go index 67fd3f2e..a385dd3e 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...) } } diff --git a/responses/validate_headers.go b/responses/validate_headers.go index f12680c9..a163f3c7 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 From 46c57ee87453b267f52622641ec76afe81bfa3b9 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 10:40:23 +0100 Subject: [PATCH 09/13] test: cover query, invalid and concurrent validation shapes Three benchmark scenarios complete the gateway coverage: - Query-heavy GET: four query parameters and an enum header parameter. - Invalid GET: an integer path parameter that fails its schema, the error-construction shape. - Parallel GET: the plain shape from many goroutines, which is how a gateway shares one validator and its caches. Main vs branch, n=10, benchstat: allocs/op 97 -> 35 (plain), 324 -> 94 (parameter-heavy), 326 -> 96 (invalid), 122 -> 111 (query), 40 -> 33 (response). Concurrent requests hold the cache win: -64% allocs and -61% wall time at 12 goroutines. --- validator_request_bench_test.go | 88 ++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/validator_request_bench_test.go b/validator_request_bench_test.go index 60d35dcc..303b9eed 100644 --- a/validator_request_bench_test.go +++ b/validator_request_bench_test.go @@ -13,7 +13,8 @@ import ( ) // requestBenchSpec holds the happy-path shapes a gateway validates: a single -// path parameter, several parameters of mixed locations, and a JSON body. +// 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 @@ -79,6 +80,37 @@ paths: 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 { @@ -185,3 +217,57 @@ func BenchmarkValidateHttpRequestSyncJSONBody(b *testing.B) { } } } + +// 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)) + } + } + }) +} From 3150fb02b1ff6e08b92ea49ad9240e7450a09124 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 10:52:16 +0100 Subject: [PATCH 10/13] test: add scenario benchmarks for formats, servers, bodies and routes Five scenarios close the coverage gaps in the benchmark suite: format assertions enabled, strict server matching, a form-urlencoded body, a JSON response body, and a document with 200 path items. Main vs branch, n=10, benchstat, allocs/op: - format assertions: 98 -> 36 (-63%), sec/op -60% - strict server matching: 99 -> 37 (-63%), sec/op -44% - form-urlencoded body: 97 -> 85 (-12%) - JSON response body: 53 -> 44 (-17%) - large route table (200 paths): 114 -> 35 (-69%), sec/op -60% The regex cache dominates the win on shapes that route through many templates; the parameter and body shapes hold smaller, steady gains. --- validator_scenario_bench_test.go | 249 +++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 validator_scenario_bench_test.go diff --git a/validator_scenario_bench_test.go b/validator_scenario_bench_test.go new file mode 100644 index 00000000..8ddfcd01 --- /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)) + } + } +} From 83bd250693cf67353b49bedabf4015bf0ff45b17 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 16:31:25 +0100 Subject: [PATCH 11/13] test: cover the new cache and merge branches - ExtractRawQueryValues: nil return for an empty query. - ExtractParamsForOperation: both sides empty, one side empty, and the exact-size merge, each asserting the model slice stays untouched. - GetRegexForPath: regexp compilation failure on an invalid custom pattern, and rejection of capturing groups in custom patterns. The one uncovered line left in compilePathRegexEntry is the bytes.Buffer write error, which cannot occur: buffer writes panic on overflow instead of returning an error. --- helpers/parameter_utilities_test.go | 42 +++++++++++++++++++++++++++++ helpers/regex_maker_test.go | 13 +++++++++ 2 files changed, 55 insertions(+) diff --git a/helpers/parameter_utilities_test.go b/helpers/parameter_utilities_test.go index 6a450c89..44694572 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_test.go b/helpers/regex_maker_test.go index dcdd396b..fccf7201 100644 --- a/helpers/regex_maker_test.go +++ b/helpers/regex_maker_test.go @@ -212,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") +} From 14b46ebca3bb0a51682667278ee8ba5c0acfdc90 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 16:45:43 +0100 Subject: [PATCH 12/13] test: cover the unbraced-segment skip in path parameter validation The continue after GetBraceIndicesForPath fails protects against a user-supplied regex cache that holds a regex under a segment key with unbalanced braces: the regex matches, but the segment carries no brace indices. Pin the defensive behavior: the segment is skipped, the declared path parameter still validates, and no error is reported for the skipped segment. --- parameters/path_parameters_test.go | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/parameters/path_parameters_test.go b/parameters/path_parameters_test.go index 0d3ac51c..eb5e576b 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) +} From 53712690183155a2592eb627c6f310e689dc2c88 Mon Sep 17 00:00:00 2001 From: Emilien Puget Date: Wed, 4 Nov 2026 17:00:59 +0100 Subject: [PATCH 13/13] perf: build path regex patterns with strings.Builder The pattern buffer writes cannot fail: bytes.Buffer panics on overflow instead of returning an error, so the fmt.Fprintf error branch was unreachable. Assembling the pattern with strings.Builder removes the dead error check and the fmt call with its interface boxing. Cold compile path, paired n=10: 93 -> 90 allocs/op (-3.2%, p<0.001), sec/op neutral. --- helpers/regex_maker.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/helpers/regex_maker.go b/helpers/regex_maker.go index 09bfa2e5..2842d916 100644 --- a/helpers/regex_maker.go +++ b/helpers/regex_maker.go @@ -1,7 +1,6 @@ package helpers import ( - "bytes" "fmt" "regexp" "strings" @@ -118,7 +117,11 @@ func compilePathRegexEntry(tpl string) (pathRegexEntry, error) { // 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 { @@ -139,10 +142,10 @@ func compilePathRegexEntry(tpl string) (pathRegexEntry, error) { } // Build the regexp pattern. - _, err := fmt.Fprintf(pattern, "%s(%s)", regexp.QuoteMeta(raw), patt) - if err != nil { - return pathRegexEntry{}, err - } + pattern.WriteString(regexp.QuoteMeta(raw)) + pattern.WriteByte('(') + pattern.WriteString(patt) + pattern.WriteByte(')') }