Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 127 additions & 80 deletions schema_validation/validate_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
80 changes: 80 additions & 0 deletions schema_validation/validate_schema_extract_errors_test.go
Original file line number Diff line number Diff line change
@@ -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)
}