Skip to content

Commit 6038995

Browse files
committed
perf: add schema cache lookup to parameter validation
1 parent 8828f82 commit 6038995

2 files changed

Lines changed: 245 additions & 31 deletions

File tree

parameters/validate_parameter.go

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
stdError "errors"
2020

21+
"github.com/pb33f/libopenapi-validator/cache"
2122
"github.com/pb33f/libopenapi-validator/config"
2223
"github.com/pb33f/libopenapi-validator/errors"
2324
"github.com/pb33f/libopenapi-validator/helpers"
@@ -35,16 +36,41 @@ func ValidateSingleParameterSchema(
3536
pathTemplate string,
3637
operation string,
3738
) (validationErrors []*errors.ValidationError) {
38-
// Get the JSON Schema for the parameter definition.
39-
jsonSchema, err := buildJsonRender(schema)
40-
if err != nil {
41-
return validationErrors
39+
var jsch *jsonschema.Schema
40+
var jsonSchema []byte
41+
42+
// Try cache lookup first - avoids expensive schema compilation on each request
43+
if o != nil && o.SchemaCache != nil && schema != nil && schema.GoLow() != nil {
44+
hash := schema.GoLow().Hash()
45+
if cached, ok := o.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil {
46+
jsch = cached.CompiledSchema
47+
}
4248
}
4349

44-
// Attempt to compile the JSON Schema
45-
jsch, err := helpers.NewCompiledSchema(name, jsonSchema, o)
46-
if err != nil {
47-
return validationErrors
50+
// Cache miss - compile the schema
51+
if jsch == nil {
52+
// Get the JSON Schema for the parameter definition.
53+
var err error
54+
jsonSchema, err = buildJsonRender(schema)
55+
if err != nil {
56+
return validationErrors
57+
}
58+
59+
// Attempt to compile the JSON Schema
60+
jsch, err = helpers.NewCompiledSchema(name, jsonSchema, o)
61+
if err != nil {
62+
return validationErrors
63+
}
64+
65+
// Store in cache for future requests
66+
if o != nil && o.SchemaCache != nil && schema != nil && schema.GoLow() != nil {
67+
hash := schema.GoLow().Hash()
68+
o.SchemaCache.Store(hash, &cache.SchemaCacheEntry{
69+
Schema: schema,
70+
RenderedJSON: jsonSchema,
71+
CompiledSchema: jsch,
72+
})
73+
}
4874
}
4975

5076
// Validate the object and report any errors.
@@ -94,13 +120,60 @@ func ValidateParameterSchema(
94120
validationOptions *config.ValidationOptions,
95121
) []*errors.ValidationError {
96122
var validationErrors []*errors.ValidationError
123+
var jsch *jsonschema.Schema
124+
var jsonSchema []byte
97125

98-
// 1. build a JSON render of the schema.
99-
renderCtx := base.NewInlineRenderContextForValidation()
100-
renderedSchema, _ := schema.RenderInlineWithContext(renderCtx)
101-
jsonSchema, _ := utils.ConvertYAMLtoJSON(renderedSchema)
126+
// Try cache lookup first - avoids expensive schema compilation on each request
127+
if validationOptions != nil && validationOptions.SchemaCache != nil && schema != nil && schema.GoLow() != nil {
128+
hash := schema.GoLow().Hash()
129+
if cached, ok := validationOptions.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil {
130+
jsch = cached.CompiledSchema
131+
jsonSchema = cached.RenderedJSON
132+
}
133+
}
134+
135+
// Cache miss - render and compile the schema
136+
if jsch == nil {
137+
// 1. build a JSON render of the schema.
138+
renderCtx := base.NewInlineRenderContextForValidation()
139+
renderedSchema, _ := schema.RenderInlineWithContext(renderCtx)
140+
referenceSchema := string(renderedSchema)
141+
jsonSchema, _ = utils.ConvertYAMLtoJSON(renderedSchema)
142+
143+
// 2. create a new json schema compiler and add the schema to it
144+
var err error
145+
jsch, err = helpers.NewCompiledSchema(name, jsonSchema, validationOptions)
146+
if err != nil {
147+
// schema compilation failed, return validation error instead of panicking
148+
validationErrors = append(validationErrors, &errors.ValidationError{
149+
ValidationType: validationType,
150+
ValidationSubType: subValType,
151+
Message: fmt.Sprintf("%s '%s' failed schema compilation", entity, name),
152+
Reason: fmt.Sprintf("%s '%s' schema compilation failed: %s",
153+
reasonEntity, name, err.Error()),
154+
SpecLine: 1,
155+
SpecCol: 0,
156+
ParameterName: name,
157+
HowToFix: "check the parameter schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs",
158+
Context: string(jsonSchema),
159+
})
160+
return validationErrors
161+
}
102162

103-
// 2. decode the object into a json blob.
163+
// Store in cache for future requests
164+
if validationOptions != nil && validationOptions.SchemaCache != nil && schema != nil && schema.GoLow() != nil {
165+
hash := schema.GoLow().Hash()
166+
validationOptions.SchemaCache.Store(hash, &cache.SchemaCacheEntry{
167+
Schema: schema,
168+
RenderedInline: renderedSchema,
169+
ReferenceSchema: referenceSchema,
170+
RenderedJSON: jsonSchema,
171+
CompiledSchema: jsch,
172+
})
173+
}
174+
}
175+
176+
// 3. decode the object into a json blob.
104177
var decodedObj interface{}
105178
rawIsMap := false
106179
validEncoding := false
@@ -125,24 +198,6 @@ func ValidateParameterSchema(
125198
}
126199
validEncoding = true
127200
}
128-
// 3. create a new json schema compiler and add the schema to it
129-
jsch, err := helpers.NewCompiledSchema(name, jsonSchema, validationOptions)
130-
if err != nil {
131-
// schema compilation failed, return validation error instead of panicking
132-
validationErrors = append(validationErrors, &errors.ValidationError{
133-
ValidationType: validationType,
134-
ValidationSubType: subValType,
135-
Message: fmt.Sprintf("%s '%s' failed schema compilation", entity, name),
136-
Reason: fmt.Sprintf("%s '%s' schema compilation failed: %s",
137-
reasonEntity, name, err.Error()),
138-
SpecLine: 1,
139-
SpecCol: 0,
140-
ParameterName: name,
141-
HowToFix: "check the parameter schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs",
142-
Context: string(jsonSchema),
143-
})
144-
return validationErrors
145-
}
146201

147202
// 4. validate the object against the schema
148203
var scErrs error

parameters/validate_parameter_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3"
1414

15+
"github.com/pb33f/libopenapi-validator/cache"
1516
"github.com/pb33f/libopenapi-validator/config"
1617
"github.com/pb33f/libopenapi-validator/helpers"
1718
)
@@ -678,3 +679,161 @@ func BenchmarkValidationWithRegexCache(b *testing.B) {
678679
validator.ValidatePathParams(req)
679680
}
680681
}
682+
683+
// cacheTestSpec is an OpenAPI spec for testing cache behavior
684+
var cacheTestSpec = []byte(`{
685+
"openapi": "3.1.0",
686+
"info": {
687+
"title": "Cache Test API",
688+
"version": "1.0.0"
689+
},
690+
"paths": {
691+
"/items/{id}": {
692+
"get": {
693+
"operationId": "getItem",
694+
"parameters": [
695+
{
696+
"name": "id",
697+
"in": "path",
698+
"required": true,
699+
"schema": {
700+
"type": "string",
701+
"minLength": 1,
702+
"maxLength": 64
703+
}
704+
},
705+
{
706+
"name": "limit",
707+
"in": "query",
708+
"schema": {
709+
"type": "integer",
710+
"minimum": 1,
711+
"maximum": 100
712+
}
713+
}
714+
],
715+
"responses": {
716+
"200": {
717+
"description": "OK"
718+
}
719+
}
720+
}
721+
}
722+
}
723+
}`)
724+
725+
// Test_ParameterValidation_CacheUsage verifies that parameter validation uses the schema cache.
726+
// This test validates that:
727+
// 1. Cache is populated after the first validation
728+
// 2. Subsequent validations reuse the cached compiled schemas
729+
// 3. Validation still produces correct results when using cached schemas
730+
func Test_ParameterValidation_CacheUsage(t *testing.T) {
731+
doc, err := libopenapi.NewDocument(cacheTestSpec)
732+
require.NoError(t, err, "Failed to create document")
733+
734+
v3Model, errs := doc.BuildV3Model()
735+
require.Nil(t, errs, "Failed to build v3 model")
736+
737+
// Create options with cache (default behavior)
738+
opts := config.NewValidationOptions()
739+
require.NotNil(t, opts.SchemaCache, "Schema cache should be initialized by default")
740+
741+
validator := NewParameterValidator(&v3Model.Model, config.WithExistingOpts(opts))
742+
743+
// First request - should populate cache
744+
req1, _ := http.NewRequest("GET", "/items/abc123?limit=50", nil)
745+
isSuccess1, errors1 := validator.ValidateQueryParams(req1)
746+
assert.True(t, isSuccess1, "First validation should succeed")
747+
assert.Empty(t, errors1, "First validation should have no errors")
748+
749+
// Count cached entries (should have at least the limit parameter schema)
750+
cacheCount := 0
751+
opts.SchemaCache.Range(func(key uint64, value *cache.SchemaCacheEntry) bool {
752+
cacheCount++
753+
return true
754+
})
755+
assert.Greater(t, cacheCount, 0, "Cache should have entries after first validation")
756+
757+
// Second request with different valid value - should use cached schema
758+
req2, _ := http.NewRequest("GET", "/items/xyz789?limit=75", nil)
759+
isSuccess2, errors2 := validator.ValidateQueryParams(req2)
760+
assert.True(t, isSuccess2, "Second validation should succeed")
761+
assert.Empty(t, errors2, "Second validation should have no errors")
762+
763+
// Third request with invalid value - should still use cached schema but fail validation
764+
req3, _ := http.NewRequest("GET", "/items/test?limit=999", nil)
765+
isSuccess3, errors3 := validator.ValidateQueryParams(req3)
766+
assert.False(t, isSuccess3, "Third validation should fail (limit > maximum)")
767+
assert.NotEmpty(t, errors3, "Third validation should have errors")
768+
}
769+
770+
// Test_ParameterValidation_WithoutCache verifies that validation works when cache is disabled.
771+
func Test_ParameterValidation_WithoutCache(t *testing.T) {
772+
doc, err := libopenapi.NewDocument(cacheTestSpec)
773+
require.NoError(t, err, "Failed to create document")
774+
775+
v3Model, errs := doc.BuildV3Model()
776+
require.Nil(t, errs, "Failed to build v3 model")
777+
778+
// Create options without cache
779+
opts := config.NewValidationOptions(config.WithSchemaCache(nil))
780+
require.Nil(t, opts.SchemaCache, "Schema cache should be nil")
781+
782+
validator := NewParameterValidator(&v3Model.Model, config.WithExistingOpts(opts))
783+
784+
// Validation should still work without cache
785+
req, _ := http.NewRequest("GET", "/items/abc123?limit=50", nil)
786+
isSuccess, errors := validator.ValidateQueryParams(req)
787+
assert.True(t, isSuccess, "Validation should succeed without cache")
788+
assert.Empty(t, errors, "Validation should have no errors")
789+
790+
// Validation with invalid value should fail
791+
req2, _ := http.NewRequest("GET", "/items/abc123?limit=999", nil)
792+
isSuccess2, errors2 := validator.ValidateQueryParams(req2)
793+
assert.False(t, isSuccess2, "Validation should fail for invalid value")
794+
assert.NotEmpty(t, errors2, "Validation should report errors")
795+
}
796+
797+
// Test_ParameterValidation_CacheConsistency verifies that cached schemas produce
798+
// the same validation results as freshly compiled schemas.
799+
func Test_ParameterValidation_CacheConsistency(t *testing.T) {
800+
doc, err := libopenapi.NewDocument(cacheTestSpec)
801+
require.NoError(t, err, "Failed to create document")
802+
803+
v3Model, errs := doc.BuildV3Model()
804+
require.Nil(t, errs, "Failed to build v3 model")
805+
806+
// Run the same validations with and without cache
807+
testCases := []struct {
808+
name string
809+
url string
810+
expected bool
811+
}{
812+
{"valid_limit", "/items/abc?limit=50", true},
813+
{"limit_at_max", "/items/abc?limit=100", true},
814+
{"limit_at_min", "/items/abc?limit=1", true},
815+
{"limit_too_high", "/items/abc?limit=101", false},
816+
{"limit_too_low", "/items/abc?limit=0", false},
817+
}
818+
819+
// First run with cache
820+
optsWithCache := config.NewValidationOptions()
821+
validatorWithCache := NewParameterValidator(&v3Model.Model, config.WithExistingOpts(optsWithCache))
822+
823+
// Second run without cache
824+
optsNoCache := config.NewValidationOptions(config.WithSchemaCache(nil))
825+
validatorNoCache := NewParameterValidator(&v3Model.Model, config.WithExistingOpts(optsNoCache))
826+
827+
for _, tc := range testCases {
828+
t.Run(tc.name, func(t *testing.T) {
829+
req, _ := http.NewRequest("GET", tc.url, nil)
830+
831+
successWithCache, errorsWithCache := validatorWithCache.ValidateQueryParams(req)
832+
successNoCache, errorsNoCache := validatorNoCache.ValidateQueryParams(req)
833+
834+
assert.Equal(t, tc.expected, successWithCache, "Cached validation result mismatch for %s", tc.name)
835+
assert.Equal(t, successWithCache, successNoCache, "Cache vs no-cache results should match for %s", tc.name)
836+
assert.Equal(t, len(errorsWithCache), len(errorsNoCache), "Error count should match for %s", tc.name)
837+
})
838+
}
839+
}

0 commit comments

Comments
 (0)