Skip to content

Commit 564f49e

Browse files
committed
cache validation and avoid re-encoding
1 parent 846c366 commit 564f49e

1 file changed

Lines changed: 127 additions & 80 deletions

File tree

schema_validation/validate_schema.go

Lines changed: 127 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import (
88
"errors"
99
"fmt"
1010
"log/slog"
11+
"math"
1112
"os"
1213
"reflect"
1314
"regexp"
1415
"strconv"
1516
"sync"
1617

18+
"github.com/pb33f/libopenapi-validator/cache"
1719
"github.com/pb33f/libopenapi/datamodel/high/base"
18-
"github.com/pb33f/libopenapi/utils"
1920
"github.com/santhosh-tekuri/jsonschema/v6"
2021
"go.yaml.in/yaml/v4"
2122
"golang.org/x/text/language"
@@ -123,49 +124,114 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload
123124
}
124125

125126
var renderedSchema []byte
126-
127-
// render the schema, to be used for validation, stop this from running concurrently, mutations are made to state
128-
// and, it will cause async issues.
129-
// Create isolated render context for this validation to prevent false positive cycle detection
130-
// when multiple validations run concurrently.
131-
// Use validation mode to force full inlining of discriminator refs - the JSON schema compiler
132-
// needs a self-contained schema without unresolved $refs.
133-
renderCtx := base.NewInlineRenderContextForValidation()
134-
s.lock.Lock()
135-
var e error
136-
renderedSchema, e = schema.RenderInlineWithContext(renderCtx)
137-
if e != nil {
138-
// schema cannot be rendered, so it's not valid!
139-
violation := &liberrors.SchemaValidationFailure{
140-
Reason: e.Error(),
141-
Location: "unavailable",
142-
ReferenceSchema: string(renderedSchema),
143-
ReferenceObject: string(payload),
127+
var renderedNode *yaml.Node
128+
var compiledSchema *jsonschema.Schema
129+
130+
// Check cache first — reuses existing SchemaCache (populated by NewValidationOptions).
131+
var cacheKey uint64
132+
canCache := s.options.SchemaCache != nil && schema.GoLow() != nil
133+
if canCache {
134+
// Include version in key so 3.0 (nullable) and 3.1 compile differently.
135+
cacheKey = schema.GoLow().Hash() ^ uint64(math.Float32bits(version))
136+
if cached, ok := s.options.SchemaCache.Load(cacheKey); ok &&
137+
cached != nil && cached.CompiledSchema != nil {
138+
renderedSchema = cached.RenderedInline
139+
renderedNode = cached.RenderedNode
140+
compiledSchema = cached.CompiledSchema
144141
}
145-
validationErrors = append(validationErrors, &liberrors.ValidationError{
146-
ValidationType: helpers.RequestBodyValidation,
147-
ValidationSubType: helpers.Schema,
148-
Message: "schema does not pass validation",
149-
Reason: fmt.Sprintf("The schema cannot be decoded: %s", e.Error()),
150-
SpecLine: schema.GoLow().GetRootNode().Line,
151-
SpecCol: schema.GoLow().GetRootNode().Column,
152-
SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
153-
HowToFix: liberrors.HowToFixInvalidSchema,
154-
Context: string(renderedSchema),
155-
})
142+
}
143+
144+
// Cache miss — render, convert to JSON, and compile.
145+
if compiledSchema == nil {
146+
renderCtx := base.NewInlineRenderContextForValidation()
147+
s.lock.Lock()
148+
nodeIface, renderErr := schema.MarshalYAMLInlineWithContext(renderCtx)
156149
s.lock.Unlock()
157-
return false, validationErrors
158150

159-
}
160-
s.lock.Unlock()
151+
if renderErr != nil {
152+
violation := &liberrors.SchemaValidationFailure{
153+
Reason: renderErr.Error(),
154+
Location: "unavailable",
155+
ReferenceSchema: string(renderedSchema),
156+
ReferenceObject: string(payload),
157+
}
158+
validationErrors = append(validationErrors, &liberrors.ValidationError{
159+
ValidationType: helpers.RequestBodyValidation,
160+
ValidationSubType: helpers.Schema,
161+
Message: "schema does not pass validation",
162+
Reason: fmt.Sprintf("The schema cannot be decoded: %s", renderErr.Error()),
163+
SpecLine: schema.GoLow().GetRootNode().Line,
164+
SpecCol: schema.GoLow().GetRootNode().Column,
165+
SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
166+
HowToFix: liberrors.HowToFixInvalidSchema,
167+
Context: string(renderedSchema),
168+
})
169+
return false, validationErrors
170+
}
171+
172+
// MarshalYAMLInlineWithContext returns *yaml.Node (from NodeBuilder.Render)
173+
renderedNode, _ = nodeIface.(*yaml.Node)
161174

162-
jsonSchema, _ := utils.ConvertYAMLtoJSON(renderedSchema)
175+
// yaml.Node → map → JSON bytes (skips yaml.Marshal + yaml.Unmarshal round-trip)
176+
var jsonMap map[string]interface{}
177+
if renderedNode != nil {
178+
_ = renderedNode.Decode(&jsonMap)
179+
}
180+
jsonSchema, _ := json.Marshal(jsonMap)
181+
182+
// YAML bytes generated once for error messages / context strings
183+
renderedSchema, _ = yaml.Marshal(renderedNode)
184+
185+
path := ""
186+
if schema.GoLow().GetIndex() != nil {
187+
path = schema.GoLow().GetIndex().GetSpecAbsolutePath()
188+
}
189+
190+
var compileErr error
191+
compiledSchema, compileErr = helpers.NewCompiledSchemaWithVersion(path, jsonSchema, s.options, version)
192+
if compileErr != nil {
193+
violation := &liberrors.SchemaValidationFailure{
194+
Reason: compileErr.Error(),
195+
Location: "schema compilation",
196+
ReferenceSchema: string(renderedSchema),
197+
ReferenceObject: string(payload),
198+
}
199+
line := 1
200+
col := 0
201+
if schema.GoLow().Type.KeyNode != nil {
202+
line = schema.GoLow().Type.KeyNode.Line
203+
col = schema.GoLow().Type.KeyNode.Column
204+
}
205+
validationErrors = append(validationErrors, &liberrors.ValidationError{
206+
ValidationType: helpers.Schema,
207+
ValidationSubType: helpers.Schema,
208+
Message: "schema compilation failed",
209+
Reason: fmt.Sprintf("Schema compilation failed: %s", compileErr.Error()),
210+
SpecLine: line,
211+
SpecCol: col,
212+
SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
213+
HowToFix: liberrors.HowToFixInvalidSchema,
214+
Context: string(renderedSchema),
215+
})
216+
return false, validationErrors
217+
}
218+
219+
// Store in cache for subsequent validations of the same schema.
220+
if canCache && compiledSchema != nil {
221+
s.options.SchemaCache.Store(cacheKey, &cache.SchemaCacheEntry{
222+
Schema: schema,
223+
RenderedInline: renderedSchema,
224+
ReferenceSchema: string(renderedSchema),
225+
RenderedJSON: jsonSchema,
226+
CompiledSchema: compiledSchema,
227+
RenderedNode: renderedNode,
228+
})
229+
}
230+
}
163231

164232
if decodedObject == nil && len(payload) > 0 {
165233
err := json.Unmarshal(payload, &decodedObject)
166234
if err != nil {
167-
168-
// cannot decode the request body, so it's not valid
169235
violation := &liberrors.SchemaValidationFailure{
170236
Reason: err.Error(),
171237
Location: "unavailable",
@@ -191,45 +257,12 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload
191257
})
192258
return false, validationErrors
193259
}
194-
195-
}
196-
197-
path := ""
198-
if schema.GoLow().GetIndex() != nil {
199-
path = schema.GoLow().GetIndex().GetSpecAbsolutePath()
200260
}
201-
jsch, err := helpers.NewCompiledSchemaWithVersion(path, jsonSchema, s.options, version)
202261

203262
var schemaValidationErrors []*liberrors.SchemaValidationFailure
204-
if err != nil {
205-
violation := &liberrors.SchemaValidationFailure{
206-
Reason: err.Error(),
207-
Location: "schema compilation",
208-
ReferenceSchema: string(renderedSchema),
209-
ReferenceObject: string(payload),
210-
}
211-
line := 1
212-
col := 0
213-
if schema.GoLow().Type.KeyNode != nil {
214-
line = schema.GoLow().Type.KeyNode.Line
215-
col = schema.GoLow().Type.KeyNode.Column
216-
}
217-
validationErrors = append(validationErrors, &liberrors.ValidationError{
218-
ValidationType: helpers.Schema,
219-
ValidationSubType: helpers.Schema,
220-
Message: "schema compilation failed",
221-
Reason: fmt.Sprintf("Schema compilation failed: %s", err.Error()),
222-
SpecLine: line,
223-
SpecCol: col,
224-
SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
225-
HowToFix: liberrors.HowToFixInvalidSchema,
226-
Context: string(renderedSchema),
227-
})
228-
return false, validationErrors
229-
}
230263

231-
if jsch != nil && decodedObject != nil {
232-
scErrs := jsch.Validate(decodedObject)
264+
if compiledSchema != nil && decodedObject != nil {
265+
scErrs := compiledSchema.Validate(decodedObject)
233266
if scErrs != nil {
234267

235268
var jk *jsonschema.ValidationError
@@ -238,7 +271,7 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload
238271
// flatten the validationErrors
239272
schFlatErr := jk.BasicOutput().Errors
240273
schemaValidationErrors = extractBasicErrors(schFlatErr, renderedSchema,
241-
decodedObject, payload, jk, schemaValidationErrors)
274+
renderedNode, decodedObject, payload, jk, schemaValidationErrors)
242275
}
243276
line := 1
244277
col := 0
@@ -266,13 +299,28 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload
266299
}
267300

268301
func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit,
269-
renderedSchema []byte, decodedObject interface{},
302+
renderedSchema []byte, renderedNode *yaml.Node,
303+
decodedObject interface{},
270304
payload []byte, jk *jsonschema.ValidationError,
271305
schemaValidationErrors []*liberrors.SchemaValidationFailure,
272306
) []*liberrors.SchemaValidationFailure {
273307
// Extract property name info once before processing errors (performance optimization)
274308
propertyInfo := extractPropertyNameFromError(jk)
275309

310+
// Determine root content node ONCE (not per-error).
311+
// NodeBuilder.Render() returns MappingNode directly, no DocumentNode unwrapping needed.
312+
var rootNode *yaml.Node
313+
if renderedNode != nil {
314+
rootNode = renderedNode
315+
} else if len(renderedSchema) > 0 {
316+
// Fallback: parse bytes ONCE
317+
var docNode yaml.Node
318+
_ = yaml.Unmarshal(renderedSchema, &docNode)
319+
if len(docNode.Content) > 0 {
320+
rootNode = docNode.Content[0]
321+
}
322+
}
323+
276324
for q := range schFlatErrs {
277325
er := schFlatErrs[q]
278326

@@ -282,12 +330,11 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit,
282330
}
283331
if er.Error != nil {
284332

285-
// re-encode the schema.
286-
var renderedNode yaml.Node
287-
_ = yaml.Unmarshal(renderedSchema, &renderedNode)
288-
289333
// locate the violated property in the schema
290-
located := LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation)
334+
var located *yaml.Node
335+
if rootNode != nil {
336+
located = LocateSchemaPropertyNodeByJSONPath(rootNode, er.KeywordLocation)
337+
}
291338

292339
// extract the element specified by the instance
293340
val := instanceLocationRegex.FindStringSubmatch(er.InstanceLocation)
@@ -331,9 +378,9 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit,
331378
// location of the violation within the rendered schema.
332379
violation.Line = line
333380
violation.Column = located.Column
334-
} else {
381+
} else if rootNode != nil {
335382
// handles property name validation errors that don't provide useful InstanceLocation
336-
applyPropertyNameFallback(propertyInfo, renderedNode.Content[0], violation)
383+
applyPropertyNameFallback(propertyInfo, rootNode, violation)
337384
}
338385
schemaValidationErrors = append(schemaValidationErrors, violation)
339386
}

0 commit comments

Comments
 (0)