Skip to content

Commit 0bb210b

Browse files
committed
perf: use cached rendered schemas for error messages in parameter validation
1 parent 6038995 commit 0bb210b

7 files changed

Lines changed: 213 additions & 57 deletions

parameters/cookie_parameters.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package parameters
55

66
import (
7-
"encoding/json"
87
"fmt"
98
"net/http"
109
"strconv"
@@ -70,13 +69,8 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request,
7069
sch = p.Schema.Schema()
7170
}
7271

73-
// Render schema once for ReferenceSchema field in errors
74-
var renderedSchema string
75-
if sch != nil {
76-
rendered, _ := sch.RenderInline()
77-
schemaBytes, _ := json.Marshal(rendered)
78-
renderedSchema = string(schemaBytes)
79-
}
72+
// Get rendered schema for ReferenceSchema field in errors (uses cache if available)
73+
renderedSchema := GetRenderedSchema(sch, v.options)
8074

8175
pType := sch.Type
8276

parameters/header_parameters.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package parameters
55

66
import (
7-
"encoding/json"
87
"fmt"
98
"net/http"
109
"strconv"
@@ -60,13 +59,8 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request,
6059
sch = p.Schema.Schema()
6160
}
6261

63-
// Render schema once for ReferenceSchema field in errors
64-
var renderedSchema string
65-
if sch != nil {
66-
rendered, _ := sch.RenderInline()
67-
schemaBytes, _ := json.Marshal(rendered)
68-
renderedSchema = string(schemaBytes)
69-
}
62+
// Get rendered schema for ReferenceSchema field in errors (uses cache if available)
63+
renderedSchema := GetRenderedSchema(sch, v.options)
7064

7165
pType := sch.Type
7266

@@ -204,15 +198,10 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request,
204198
}
205199
} else {
206200
if p.Required != nil && *p.Required {
207-
// Render schema for missing required parameter
201+
// Get rendered schema for missing required parameter (uses cache if available)
208202
var renderedSchema string
209203
if p.Schema != nil {
210-
sch := p.Schema.Schema()
211-
if sch != nil {
212-
rendered, _ := sch.RenderInline()
213-
schemaBytes, _ := json.Marshal(rendered)
214-
renderedSchema = string(schemaBytes)
215-
}
204+
renderedSchema = GetRenderedSchema(p.Schema.Schema(), v.options)
216205
}
217206
validationErrors = append(validationErrors, errors.HeaderParameterMissing(p, pathValue, operation, renderedSchema))
218207
}

parameters/path_parameters.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package parameters
55

66
import (
7-
"encoding/json"
87
"fmt"
98
"net/http"
109
"net/url"
@@ -142,13 +141,8 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p
142141
// extract the schema from the parameter
143142
sch := p.Schema.Schema()
144143

145-
// Render schema once for ReferenceSchema field in errors
146-
var renderedSchema string
147-
if sch != nil {
148-
rendered, _ := sch.RenderInline()
149-
schemaBytes, _ := json.Marshal(rendered)
150-
renderedSchema = string(schemaBytes)
151-
}
144+
// Get rendered schema for ReferenceSchema field in errors (uses cache if available)
145+
renderedSchema := GetRenderedSchema(sch, v.options)
152146

153147
// check enum (if present)
154148
enumCheck := func(decodedValue string) {
@@ -309,13 +303,8 @@ func (v *paramValidator) ValidatePathParamsWithPathItem(request *http.Request, p
309303
if sch.Items != nil && sch.Items.IsA() {
310304
iSch := sch.Items.A.Schema()
311305

312-
// Render items schema once for ReferenceSchema field in array errors
313-
var renderedItemsSchema string
314-
if iSch != nil {
315-
rendered, _ := iSch.RenderInline()
316-
schemaBytes, _ := json.Marshal(rendered)
317-
renderedItemsSchema = string(schemaBytes)
318-
}
306+
// Get rendered items schema for ReferenceSchema field in errors (uses cache if available)
307+
renderedItemsSchema := GetRenderedSchema(iSch, v.options)
319308

320309
for n := range iSch.Type {
321310
// determine how to explode the array

parameters/query_parameters.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,8 @@ doneLooking:
121121
}
122122
}
123123

124-
// Render schema once for ReferenceSchema field in errors
125-
var renderedSchema string
126-
if sch != nil {
127-
rendered, _ := sch.RenderInline()
128-
schemaBytes, _ := json.Marshal(rendered)
129-
renderedSchema = string(schemaBytes)
130-
}
124+
// Get rendered schema for ReferenceSchema field in errors (uses cache if available)
125+
renderedSchema := GetRenderedSchema(sch, v.options)
131126

132127
pType := sch.Type
133128

@@ -263,12 +258,8 @@ doneLooking:
263258
break
264259
}
265260
}
266-
var renderedSchema string
267-
if sch != nil {
268-
rendered, _ := sch.RenderInline()
269-
schemaBytes, _ := json.Marshal(rendered)
270-
renderedSchema = string(schemaBytes)
271-
}
261+
// Get rendered schema for ReferenceSchema field in errors (uses cache if available)
262+
renderedSchema := GetRenderedSchema(sch, v.options)
272263
validationErrors = append(validationErrors, errors.QueryParameterMissing(params[p], pathValue, operation, renderedSchema))
273264
}
274265
}

parameters/validate_parameter.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ func buildJsonRender(schema *base.Schema) ([]byte, error) {
9797
return utils.ConvertYAMLtoJSON(renderedSchema)
9898
}
9999

100+
// GetRenderedSchema returns a JSON string representation of the schema for error messages.
101+
// It first checks the schema cache for a pre-rendered version, falling back to fresh rendering.
102+
// This avoids expensive re-rendering on each validation when the cache is available.
103+
func GetRenderedSchema(schema *base.Schema, opts *config.ValidationOptions) string {
104+
if schema == nil {
105+
return ""
106+
}
107+
108+
// Try cache lookup first
109+
if opts != nil && opts.SchemaCache != nil && schema.GoLow() != nil {
110+
hash := schema.GoLow().Hash()
111+
if cached, ok := opts.SchemaCache.Load(hash); ok && cached != nil && len(cached.RenderedJSON) > 0 {
112+
return string(cached.RenderedJSON)
113+
}
114+
}
115+
116+
// Cache miss - render fresh
117+
rendered, err := schema.RenderInline()
118+
if err != nil {
119+
return ""
120+
}
121+
schemaBytes, err := json.Marshal(rendered)
122+
if err != nil {
123+
return ""
124+
}
125+
return string(schemaBytes)
126+
}
127+
100128
// ValidateParameterSchema will validate a parameter against a raw object, or a blob of json/yaml.
101129
// It will return a list of validation errors, if any.
102130
//

parameters/validate_parameter_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,3 +837,172 @@ func Test_ParameterValidation_CacheConsistency(t *testing.T) {
837837
})
838838
}
839839
}
840+
841+
// Test_GetRenderedSchema_NilSchema verifies GetRenderedSchema handles nil schema gracefully.
842+
func Test_GetRenderedSchema_NilSchema(t *testing.T) {
843+
opts := config.NewValidationOptions()
844+
result := GetRenderedSchema(nil, opts)
845+
assert.Empty(t, result, "GetRenderedSchema should return empty string for nil schema")
846+
}
847+
848+
// Test_GetRenderedSchema_NilOptions verifies GetRenderedSchema works without options.
849+
func Test_GetRenderedSchema_NilOptions(t *testing.T) {
850+
// Parse a document to get a properly initialized schema
851+
spec := []byte(`{
852+
"openapi": "3.1.0",
853+
"info": {"title": "Test", "version": "1.0.0"},
854+
"paths": {
855+
"/test": {
856+
"get": {
857+
"parameters": [{
858+
"name": "id",
859+
"in": "query",
860+
"schema": {"type": "string", "minLength": 1}
861+
}],
862+
"responses": {"200": {"description": "OK"}}
863+
}
864+
}
865+
}
866+
}`)
867+
868+
doc, err := libopenapi.NewDocument(spec)
869+
require.NoError(t, err)
870+
871+
v3Model, errs := doc.BuildV3Model()
872+
require.Nil(t, errs)
873+
874+
// Get the parameter schema
875+
pathItem := v3Model.Model.Paths.PathItems.GetOrZero("/test")
876+
param := pathItem.Get.Parameters[0]
877+
schema := param.Schema.Schema()
878+
879+
// Call with nil options - should still render the schema (returns some representation)
880+
result := GetRenderedSchema(schema, nil)
881+
assert.NotEmpty(t, result, "GetRenderedSchema should render schema even with nil options")
882+
}
883+
884+
// Test_GetRenderedSchema_CacheHit verifies GetRenderedSchema uses cached data when available.
885+
func Test_GetRenderedSchema_CacheHit(t *testing.T) {
886+
spec := []byte(`{
887+
"openapi": "3.1.0",
888+
"info": {"title": "Test", "version": "1.0.0"},
889+
"paths": {
890+
"/test": {
891+
"get": {
892+
"parameters": [{
893+
"name": "id",
894+
"in": "query",
895+
"schema": {"type": "integer", "minimum": 1}
896+
}],
897+
"responses": {"200": {"description": "OK"}}
898+
}
899+
}
900+
}
901+
}`)
902+
903+
doc, err := libopenapi.NewDocument(spec)
904+
require.NoError(t, err)
905+
906+
v3Model, errs := doc.BuildV3Model()
907+
require.Nil(t, errs)
908+
909+
// Get the parameter schema
910+
pathItem := v3Model.Model.Paths.PathItems.GetOrZero("/test")
911+
param := pathItem.Get.Parameters[0]
912+
schema := param.Schema.Schema()
913+
914+
// Create options with cache and pre-populate with known value
915+
opts := config.NewValidationOptions()
916+
hash := schema.GoLow().Hash()
917+
testCachedJSON := []byte(`{"type":"integer","minimum":1,"cached":true}`)
918+
opts.SchemaCache.Store(hash, &cache.SchemaCacheEntry{
919+
Schema: schema,
920+
RenderedJSON: testCachedJSON,
921+
})
922+
923+
// GetRenderedSchema should return the cached value
924+
result := GetRenderedSchema(schema, opts)
925+
assert.Equal(t, string(testCachedJSON), result, "GetRenderedSchema should return cached JSON")
926+
}
927+
928+
// Test_GetRenderedSchema_NilCache verifies GetRenderedSchema works when cache is disabled.
929+
func Test_GetRenderedSchema_NilCache(t *testing.T) {
930+
spec := []byte(`{
931+
"openapi": "3.1.0",
932+
"info": {"title": "Test", "version": "1.0.0"},
933+
"paths": {
934+
"/test": {
935+
"get": {
936+
"parameters": [{
937+
"name": "id",
938+
"in": "query",
939+
"schema": {"type": "boolean"}
940+
}],
941+
"responses": {"200": {"description": "OK"}}
942+
}
943+
}
944+
}
945+
}`)
946+
947+
doc, err := libopenapi.NewDocument(spec)
948+
require.NoError(t, err)
949+
950+
v3Model, errs := doc.BuildV3Model()
951+
require.Nil(t, errs)
952+
953+
// Get the parameter schema
954+
pathItem := v3Model.Model.Paths.PathItems.GetOrZero("/test")
955+
param := pathItem.Get.Parameters[0]
956+
schema := param.Schema.Schema()
957+
958+
// Create options with cache disabled
959+
opts := config.NewValidationOptions(config.WithSchemaCache(nil))
960+
require.Nil(t, opts.SchemaCache)
961+
962+
// GetRenderedSchema should still work by rendering fresh (returns some representation)
963+
result := GetRenderedSchema(schema, opts)
964+
assert.NotEmpty(t, result, "GetRenderedSchema should render schema even with nil cache")
965+
}
966+
967+
// arrayValidationSpec is used to test array parameter validation with the updated function signatures
968+
var arrayValidationSpec = []byte(`{
969+
"openapi": "3.1.0",
970+
"info": {"title": "Array Test", "version": "1.0.0"},
971+
"paths": {
972+
"/test": {
973+
"get": {
974+
"parameters": [{
975+
"name": "ids",
976+
"in": "query",
977+
"schema": {
978+
"type": "array",
979+
"items": {"type": "integer", "minimum": 1}
980+
}
981+
}],
982+
"responses": {"200": {"description": "OK"}}
983+
}
984+
}
985+
}
986+
}`)
987+
988+
// Test_ArrayValidation_ErrorContainsRenderedSchema verifies that array validation errors
989+
// still contain the rendered schema after the rendering optimization.
990+
func Test_ArrayValidation_ErrorContainsRenderedSchema(t *testing.T) {
991+
doc, err := libopenapi.NewDocument(arrayValidationSpec)
992+
require.NoError(t, err)
993+
994+
v3Model, errs := doc.BuildV3Model()
995+
require.Nil(t, errs)
996+
997+
validator := NewParameterValidator(&v3Model.Model)
998+
999+
// Request with invalid array values (strings instead of integers)
1000+
req, _ := http.NewRequest("GET", "/test?ids=abc,def", nil)
1001+
1002+
success, validationErrors := validator.ValidateQueryParams(req)
1003+
assert.False(t, success, "Validation should fail for non-integer array values")
1004+
assert.NotEmpty(t, validationErrors, "Should have validation errors")
1005+
1006+
// Verify error message is properly formatted
1007+
assert.Contains(t, validationErrors[0].Message, "ids", "Error should reference parameter name")
1008+
}

parameters/validation_functions.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,8 @@ func ValidateQueryArray(
119119
var validationErrors []*errors.ValidationError
120120
itemsSchema := sch.Items.A.Schema()
121121

122-
var renderedItemsSchema string
123-
if itemsSchema != nil {
124-
rendered, _ := itemsSchema.RenderInline()
125-
schemaBytes, _ := json.Marshal(rendered)
126-
renderedItemsSchema = string(schemaBytes)
127-
}
122+
// Get rendered items schema for ReferenceSchema field in errors (uses cache if available)
123+
renderedItemsSchema := GetRenderedSchema(itemsSchema, validationOptions)
128124

129125
// check for an exploded bit on the schema.
130126
// if it's exploded, then we need to check each item in the array

0 commit comments

Comments
 (0)