From 8db6a6b0ed5c6f334c4427ca206f1fab74917568 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Fri, 13 Feb 2026 12:48:41 -0500 Subject: [PATCH 1/2] cache validation and avoid re-encoding --- schema_validation/validate_schema.go | 207 ++++++++++++++++----------- 1 file changed, 127 insertions(+), 80 deletions(-) diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 5740f1e1..fa511f74 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -8,14 +8,15 @@ import ( "errors" "fmt" "log/slog" + "math" "os" "reflect" "regexp" "strconv" "sync" + "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi/datamodel/high/base" - "github.com/pb33f/libopenapi/utils" "github.com/santhosh-tekuri/jsonschema/v6" "go.yaml.in/yaml/v4" "golang.org/x/text/language" @@ -123,49 +124,114 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload } var renderedSchema []byte - - // render the schema, to be used for validation, stop this from running concurrently, mutations are made to state - // and, it will cause async issues. - // Create isolated render context for this validation to prevent false positive cycle detection - // when multiple validations run concurrently. - // Use validation mode to force full inlining of discriminator refs - the JSON schema compiler - // needs a self-contained schema without unresolved $refs. - renderCtx := base.NewInlineRenderContextForValidation() - s.lock.Lock() - var e error - renderedSchema, e = schema.RenderInlineWithContext(renderCtx) - if e != nil { - // schema cannot be rendered, so it's not valid! - violation := &liberrors.SchemaValidationFailure{ - Reason: e.Error(), - Location: "unavailable", - ReferenceSchema: string(renderedSchema), - ReferenceObject: string(payload), + var renderedNode *yaml.Node + var compiledSchema *jsonschema.Schema + + // Check cache first — reuses existing SchemaCache (populated by NewValidationOptions). + var cacheKey uint64 + canCache := s.options.SchemaCache != nil && schema.GoLow() != nil + if canCache { + // Include version in key so 3.0 (nullable) and 3.1 compile differently. + cacheKey = schema.GoLow().Hash() ^ uint64(math.Float32bits(version)) + if cached, ok := s.options.SchemaCache.Load(cacheKey); ok && + cached != nil && cached.CompiledSchema != nil { + renderedSchema = cached.RenderedInline + renderedNode = cached.RenderedNode + compiledSchema = cached.CompiledSchema } - validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: helpers.RequestBodyValidation, - ValidationSubType: helpers.Schema, - Message: "schema does not pass validation", - Reason: fmt.Sprintf("The schema cannot be decoded: %s", e.Error()), - SpecLine: schema.GoLow().GetRootNode().Line, - SpecCol: schema.GoLow().GetRootNode().Column, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), - }) + } + + // Cache miss — render, convert to JSON, and compile. + if compiledSchema == nil { + renderCtx := base.NewInlineRenderContextForValidation() + s.lock.Lock() + nodeIface, renderErr := schema.MarshalYAMLInlineWithContext(renderCtx) s.lock.Unlock() - return false, validationErrors - } - s.lock.Unlock() + if renderErr != nil { + violation := &liberrors.SchemaValidationFailure{ + Reason: renderErr.Error(), + Location: "unavailable", + ReferenceSchema: string(renderedSchema), + ReferenceObject: string(payload), + } + validationErrors = append(validationErrors, &liberrors.ValidationError{ + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: "schema does not pass validation", + Reason: fmt.Sprintf("The schema cannot be decoded: %s", renderErr.Error()), + SpecLine: schema.GoLow().GetRootNode().Line, + SpecCol: schema.GoLow().GetRootNode().Column, + SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), + }) + return false, validationErrors + } + + // MarshalYAMLInlineWithContext returns *yaml.Node (from NodeBuilder.Render) + renderedNode, _ = nodeIface.(*yaml.Node) - jsonSchema, _ := utils.ConvertYAMLtoJSON(renderedSchema) + // yaml.Node → map → JSON bytes (skips yaml.Marshal + yaml.Unmarshal round-trip) + var jsonMap map[string]interface{} + if renderedNode != nil { + _ = renderedNode.Decode(&jsonMap) + } + jsonSchema, _ := json.Marshal(jsonMap) + + // YAML bytes generated once for error messages / context strings + renderedSchema, _ = yaml.Marshal(renderedNode) + + path := "" + if schema.GoLow().GetIndex() != nil { + path = schema.GoLow().GetIndex().GetSpecAbsolutePath() + } + + var compileErr error + compiledSchema, compileErr = helpers.NewCompiledSchemaWithVersion(path, jsonSchema, s.options, version) + if compileErr != nil { + violation := &liberrors.SchemaValidationFailure{ + Reason: compileErr.Error(), + Location: "schema compilation", + ReferenceSchema: string(renderedSchema), + ReferenceObject: string(payload), + } + line := 1 + col := 0 + if schema.GoLow().Type.KeyNode != nil { + line = schema.GoLow().Type.KeyNode.Line + col = schema.GoLow().Type.KeyNode.Column + } + validationErrors = append(validationErrors, &liberrors.ValidationError{ + ValidationType: helpers.Schema, + ValidationSubType: helpers.Schema, + Message: "schema compilation failed", + Reason: fmt.Sprintf("Schema compilation failed: %s", compileErr.Error()), + SpecLine: line, + SpecCol: col, + SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, + HowToFix: liberrors.HowToFixInvalidSchema, + Context: string(renderedSchema), + }) + return false, validationErrors + } + + // Store in cache for subsequent validations of the same schema. + if canCache && compiledSchema != nil { + s.options.SchemaCache.Store(cacheKey, &cache.SchemaCacheEntry{ + Schema: schema, + RenderedInline: renderedSchema, + ReferenceSchema: string(renderedSchema), + RenderedJSON: jsonSchema, + CompiledSchema: compiledSchema, + RenderedNode: renderedNode, + }) + } + } if decodedObject == nil && len(payload) > 0 { err := json.Unmarshal(payload, &decodedObject) if err != nil { - - // cannot decode the request body, so it's not valid violation := &liberrors.SchemaValidationFailure{ Reason: err.Error(), Location: "unavailable", @@ -191,45 +257,12 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload }) return false, validationErrors } - - } - - path := "" - if schema.GoLow().GetIndex() != nil { - path = schema.GoLow().GetIndex().GetSpecAbsolutePath() } - jsch, err := helpers.NewCompiledSchemaWithVersion(path, jsonSchema, s.options, version) var schemaValidationErrors []*liberrors.SchemaValidationFailure - if err != nil { - violation := &liberrors.SchemaValidationFailure{ - Reason: err.Error(), - Location: "schema compilation", - ReferenceSchema: string(renderedSchema), - ReferenceObject: string(payload), - } - line := 1 - col := 0 - if schema.GoLow().Type.KeyNode != nil { - line = schema.GoLow().Type.KeyNode.Line - col = schema.GoLow().Type.KeyNode.Column - } - validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: helpers.Schema, - ValidationSubType: helpers.Schema, - Message: "schema compilation failed", - Reason: fmt.Sprintf("Schema compilation failed: %s", err.Error()), - SpecLine: line, - SpecCol: col, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), - }) - return false, validationErrors - } - if jsch != nil && decodedObject != nil { - scErrs := jsch.Validate(decodedObject) + if compiledSchema != nil && decodedObject != nil { + scErrs := compiledSchema.Validate(decodedObject) if scErrs != nil { var jk *jsonschema.ValidationError @@ -238,7 +271,7 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload // flatten the validationErrors schFlatErr := jk.BasicOutput().Errors schemaValidationErrors = extractBasicErrors(schFlatErr, renderedSchema, - decodedObject, payload, jk, schemaValidationErrors) + renderedNode, decodedObject, payload, jk, schemaValidationErrors) } line := 1 col := 0 @@ -266,13 +299,28 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload } func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, - renderedSchema []byte, decodedObject interface{}, + renderedSchema []byte, renderedNode *yaml.Node, + decodedObject interface{}, payload []byte, jk *jsonschema.ValidationError, schemaValidationErrors []*liberrors.SchemaValidationFailure, ) []*liberrors.SchemaValidationFailure { // Extract property name info once before processing errors (performance optimization) propertyInfo := extractPropertyNameFromError(jk) + // Determine root content node ONCE (not per-error). + // NodeBuilder.Render() returns MappingNode directly, no DocumentNode unwrapping needed. + var rootNode *yaml.Node + if renderedNode != nil { + rootNode = renderedNode + } else if len(renderedSchema) > 0 { + // Fallback: parse bytes ONCE + var docNode yaml.Node + _ = yaml.Unmarshal(renderedSchema, &docNode) + if len(docNode.Content) > 0 { + rootNode = docNode.Content[0] + } + } + for q := range schFlatErrs { er := schFlatErrs[q] @@ -282,12 +330,11 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, } if er.Error != nil { - // re-encode the schema. - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &renderedNode) - // locate the violated property in the schema - located := LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation) + var located *yaml.Node + if rootNode != nil { + located = LocateSchemaPropertyNodeByJSONPath(rootNode, er.KeywordLocation) + } // extract the element specified by the instance val := instanceLocationRegex.FindStringSubmatch(er.InstanceLocation) @@ -331,9 +378,9 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, // location of the violation within the rendered schema. violation.Line = line violation.Column = located.Column - } else { + } else if rootNode != nil { // handles property name validation errors that don't provide useful InstanceLocation - applyPropertyNameFallback(propertyInfo, renderedNode.Content[0], violation) + applyPropertyNameFallback(propertyInfo, rootNode, violation) } schemaValidationErrors = append(schemaValidationErrors, violation) } From dc85f5b67dbe556fc4e3aa86b6c6e1eec27e3eab Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Fri, 13 Feb 2026 16:14:15 -0500 Subject: [PATCH 2/2] error handling coverage --- .../validate_schema_extract_errors_test.go | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 schema_validation/validate_schema_extract_errors_test.go diff --git a/schema_validation/validate_schema_extract_errors_test.go b/schema_validation/validate_schema_extract_errors_test.go new file mode 100644 index 00000000..825bffaa --- /dev/null +++ b/schema_validation/validate_schema_extract_errors_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" + "golang.org/x/text/message" +) + +type stubErrorKind struct { + msg string +} + +func (s stubErrorKind) KeywordPath() []string { + return nil +} + +func (s stubErrorKind) LocalizedString(_ *message.Printer) string { + return s.msg +} + +func adjustedLine(node *yaml.Node) int { + line := node.Line + if (node.Kind == yaml.MappingNode || node.Kind == yaml.SequenceNode) && line > 0 { + line-- + } + return line +} + +func TestExtractBasicErrors_FallbackRenderedSchema_AdjustsLines(t *testing.T) { + renderedSchema := []byte(`type: object +required: + - item +properties: + item: + type: object`) + payload := []byte(`{"item":{}}`) + + flatErrors := []jsonschema.OutputUnit{ + { + KeywordLocation: "/properties/item", + AbsoluteKeywordLocation: "#/properties/item", + InstanceLocation: "/item", + Error: &jsonschema.OutputError{ + Kind: stubErrorKind{msg: "item is invalid"}, + }, + }, + { + KeywordLocation: "/required", + AbsoluteKeywordLocation: "#/required", + InstanceLocation: "/item", + Error: &jsonschema.OutputError{ + Kind: stubErrorKind{msg: "required is invalid"}, + }, + }, + } + + failures := extractBasicErrors(flatErrors, renderedSchema, nil, map[string]any{"item": map[string]any{}}, payload, nil, nil) + assert.Len(t, failures, 2) + + var docNode yaml.Node + err := yaml.Unmarshal(renderedSchema, &docNode) + assert.NoError(t, err) + assert.NotEmpty(t, docNode.Content) + + mappingNode := LocateSchemaPropertyNodeByJSONPath(docNode.Content[0], "/properties/item") + sequenceNode := LocateSchemaPropertyNodeByJSONPath(docNode.Content[0], "/required") + + assert.NotNil(t, mappingNode) + assert.NotNil(t, sequenceNode) + assert.Equal(t, adjustedLine(mappingNode), failures[0].Line) + assert.Equal(t, mappingNode.Column, failures[0].Column) + assert.Equal(t, adjustedLine(sequenceNode), failures[1].Line) + assert.Equal(t, sequenceNode.Column, failures[1].Column) +}