From 35ad3db9bfa4c58e88d7d66e643d4e7254c5b274 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Thu, 18 Jun 2026 16:24:07 -0400 Subject: [PATCH 1/3] Validate circular and multi-file schema references instead of failing Previously, schemas containing circular or external/multi-file $refs were fully inlined before compilation, which failed to render and surfaced a "failed schema rendering" error rather than validating the payload. These schemas are now compiled from named JSON Schema resources that preserve the $ref structure across documents, so circular and multi-file references validate correctly. --- cache/cache.go | 3 +- helpers/schema_compiler.go | 99 ++- helpers/schema_compiler_test.go | 69 ++ helpers/schema_output.go | 37 ++ helpers/schema_output_test.go | 43 ++ parameters/validate_parameter.go | 148 ++--- parameters/validate_parameter_test.go | 151 +++++ requests/validate_request.go | 76 +-- requests/validate_request_test.go | 196 +++++- responses/validate_body_test.go | 13 +- responses/validate_response.go | 78 +-- responses/validate_response_test.go | 15 +- schema_validation/locate_schema_property.go | 89 ++- .../locate_schema_property_test.go | 100 ++- schema_validation/schema_resources.go | 602 +++++++++++++++++ schema_validation/schema_resources_test.go | 625 ++++++++++++++++++ schema_validation/validate_schema.go | 111 ++-- .../validate_schema_extract_errors_test.go | 62 +- .../validate_schema_openapi_test.go | 23 +- validator.go | 44 +- validator_examples_test.go | 6 +- validator_test.go | 4 +- 22 files changed, 2242 insertions(+), 352 deletions(-) create mode 100644 helpers/schema_output.go create mode 100644 helpers/schema_output_test.go create mode 100644 schema_validation/schema_resources.go create mode 100644 schema_validation/schema_resources_test.go diff --git a/cache/cache.go b/cache/cache.go index 7cbdd493..b8e1eee5 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,4 +1,4 @@ -// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2025-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package cache @@ -18,6 +18,7 @@ type SchemaCacheEntry struct { RenderedJSON []byte CompiledSchema *jsonschema.Schema RenderedNode *yaml.Node + ResourceNodes map[string]*yaml.Node } // SchemaCache defines the interface for schema caching implementations. diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go index 7b6fbccf..2b8d183e 100644 --- a/helpers/schema_compiler.go +++ b/helpers/schema_compiler.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "sort" "github.com/santhosh-tekuri/jsonschema/v6" @@ -58,29 +59,8 @@ func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, options *confi compiler := NewCompilerWithOptions(options) compiler.UseLoader(NewCompilerLoader()) - // register OpenAPI vocabulary with appropriate version and coercion settings - if options != nil && options.OpenAPIMode { - var vocabVersion openapi_vocabulary.VersionType - if version >= 3.15 { // use 3.15 to avoid floating point precision issues (3.2+) - vocabVersion = openapi_vocabulary.Version32 - } else if version >= 3.05 { // use 3.05 to avoid floating point precision issues (3.1) - vocabVersion = openapi_vocabulary.Version31 - } else { - vocabVersion = openapi_vocabulary.Version30 - } - - vocab := openapi_vocabulary.NewOpenAPIVocabularyWithCoercion(vocabVersion, options.AllowScalarCoercion) - compiler.RegisterVocabulary(vocab) - compiler.AssertVocabs() - - if version < 3.05 { - jsonSchema = transformOpenAPI30Schema(jsonSchema) - } - - if options.AllowScalarCoercion { - jsonSchema = transformSchemaForCoercion(jsonSchema) - } - } + configureOpenAPICompiler(compiler, options, version) + jsonSchema = prepareSchemaForVersion(jsonSchema, options, version) decodedSchema, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonSchema)) if err != nil { @@ -99,6 +79,79 @@ func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, options *confi return jsch, nil } +// NewCompiledSchemaResourcesWithVersion compiles a schema from named JSON Schema resources. +// The name argument may include a fragment pointing at the schema inside one of the resources. +func NewCompiledSchemaResourcesWithVersion( + name string, + resources map[string][]byte, + options *config.ValidationOptions, + version float32, +) (*jsonschema.Schema, error) { + compiler := NewCompilerWithOptions(options) + compiler.UseLoader(NewCompilerLoader()) + configureOpenAPICompiler(compiler, options, version) + + resourceNames := make([]string, 0, len(resources)) + for resourceName := range resources { + resourceNames = append(resourceNames, resourceName) + } + sort.Strings(resourceNames) + + for _, resourceName := range resourceNames { + preparedSchema := prepareSchemaForVersion(resources[resourceName], options, version) + decodedSchema, err := jsonschema.UnmarshalJSON(bytes.NewReader(preparedSchema)) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON schema resource %q: %w", resourceName, err) + } + + if err = compiler.AddResource(resourceName, decodedSchema); err != nil { + return nil, fmt.Errorf("failed to add resource %q to schema compiler: %w", resourceName, err) + } + } + + jsch, err := compiler.Compile(name) + if err != nil { + return nil, fmt.Errorf("JSON schema compile failed: %s", err.Error()) + } + + return jsch, nil +} + +func configureOpenAPICompiler(compiler *jsonschema.Compiler, options *config.ValidationOptions, version float32) { + if options == nil || !options.OpenAPIMode { + return + } + + var vocabVersion openapi_vocabulary.VersionType + if version >= 3.15 { // use 3.15 to avoid floating point precision issues (3.2+) + vocabVersion = openapi_vocabulary.Version32 + } else if version >= 3.05 { // use 3.05 to avoid floating point precision issues (3.1) + vocabVersion = openapi_vocabulary.Version31 + } else { + vocabVersion = openapi_vocabulary.Version30 + } + + vocab := openapi_vocabulary.NewOpenAPIVocabularyWithCoercion(vocabVersion, options.AllowScalarCoercion) + compiler.RegisterVocabulary(vocab) + compiler.AssertVocabs() +} + +func prepareSchemaForVersion(jsonSchema []byte, options *config.ValidationOptions, version float32) []byte { + if options == nil || !options.OpenAPIMode { + return jsonSchema + } + + if version < 3.05 { + jsonSchema = transformOpenAPI30Schema(jsonSchema) + } + + if options.AllowScalarCoercion { + jsonSchema = transformSchemaForCoercion(jsonSchema) + } + + return jsonSchema +} + // transformOpenAPI30Schema transforms OpenAPI 3.0 schemas to JSON Schema 2020-12 compatible format. // Handles OAS 3.0-specific keywords: // - nullable: true → type array with "null" diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go index c78cbaba..e231a0b9 100644 --- a/helpers/schema_compiler_test.go +++ b/helpers/schema_compiler_test.go @@ -46,6 +46,75 @@ func Test_SchemaWithNilOptions(t *testing.T) { require.NotNil(t, jsch, "Did not return a compiled schema") } +func TestNewCompiledSchemaResourcesWithVersion(t *testing.T) { + resources := map[string][]byte{ + "https://example.com/root.json": []byte(`{ + "type": "object", + "properties": { + "name": { "$ref": "defs.json#/components/schemas/Name" } + } + }`), + "https://example.com/defs.json": []byte(`{ + "components": { + "schemas": { + "Name": { "type": "string" } + } + } + }`), + } + + jsch, err := NewCompiledSchemaResourcesWithVersion( + "https://example.com/root.json", + resources, + config.NewValidationOptions(), + 3.1, + ) + + require.NoError(t, err) + require.NotNil(t, jsch) + assert.NoError(t, jsch.Validate(map[string]any{"name": "ok"})) + assert.Error(t, jsch.Validate(map[string]any{"name": 42})) +} + +func TestNewCompiledSchemaResourcesWithVersion_InvalidResourceJSON(t *testing.T) { + jsch, err := NewCompiledSchemaResourcesWithVersion( + "https://example.com/root.json", + map[string][]byte{"https://example.com/root.json": []byte(`{`)}, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, jsch) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to unmarshal JSON schema resource") +} + +func TestNewCompiledSchemaResourcesWithVersion_InvalidResourceName(t *testing.T) { + jsch, err := NewCompiledSchemaResourcesWithVersion( + "https://example.com/root.json", + map[string][]byte{"%zz": []byte(`{}`)}, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, jsch) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to add resource") +} + +func TestNewCompiledSchemaResourcesWithVersion_CompileFailure(t *testing.T) { + jsch, err := NewCompiledSchemaResourcesWithVersion( + "https://example.com/root.json#/missing", + map[string][]byte{"https://example.com/root.json": []byte(`{}`)}, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, jsch) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON schema compile failed") +} + func Test_SchemaWithDefaultOptions(t *testing.T) { valOptions := config.NewValidationOptions() jsch, err := NewCompiledSchema("test", []byte(stringSchema), valOptions) diff --git a/helpers/schema_output.go b/helpers/schema_output.go new file mode 100644 index 00000000..811e1d5b --- /dev/null +++ b/helpers/schema_output.go @@ -0,0 +1,37 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package helpers + +import "github.com/santhosh-tekuri/jsonschema/v6" + +// FlattenSchemaOutputErrors returns every output unit that carries an actual validation error. +func FlattenSchemaOutputErrors(output *jsonschema.OutputUnit) []jsonschema.OutputUnit { + if output == nil { + return nil + } + + flattened := make([]jsonschema.OutputUnit, 0, countSchemaOutputErrors(*output)) + collectSchemaOutputErrors(*output, &flattened) + return flattened +} + +func countSchemaOutputErrors(output jsonschema.OutputUnit) int { + count := 0 + if output.Error != nil { + count++ + } + for _, child := range output.Errors { + count += countSchemaOutputErrors(child) + } + return count +} + +func collectSchemaOutputErrors(output jsonschema.OutputUnit, flattened *[]jsonschema.OutputUnit) { + if output.Error != nil { + *flattened = append(*flattened, output) + } + for _, child := range output.Errors { + collectSchemaOutputErrors(child, flattened) + } +} diff --git a/helpers/schema_output_test.go b/helpers/schema_output_test.go new file mode 100644 index 00000000..f318ffd5 --- /dev/null +++ b/helpers/schema_output_test.go @@ -0,0 +1,43 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package helpers + +import ( + "testing" + + "github.com/pb33f/testify/assert" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestFlattenSchemaOutputErrors(t *testing.T) { + assert.Nil(t, FlattenSchemaOutputErrors(nil)) + + output := &jsonschema.OutputUnit{ + Errors: []jsonschema.OutputUnit{ + { + KeywordLocation: "/oneOf", + Errors: []jsonschema.OutputUnit{ + { + KeywordLocation: "/oneOf/0/type", + InstanceLocation: "/name", + Error: &jsonschema.OutputError{}, + }, + }, + }, + { + KeywordLocation: "/required", + InstanceLocation: "", + Error: &jsonschema.OutputError{}, + }, + }, + } + + flattened := FlattenSchemaOutputErrors(output) + + requireLocations := []string{"/oneOf/0/type", "/required"} + assert.Len(t, flattened, len(requireLocations)) + for i, location := range requireLocations { + assert.Equal(t, location, flattened[i].KeywordLocation) + } +} diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index 0af8e374..fe2862c8 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -11,20 +11,20 @@ import ( "strings" "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" "golang.org/x/text/message" stdError "errors" - "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" + "github.com/pb33f/libopenapi-validator/schema_validation" ) +const parameterSchemaVersion = 3.1 + func ValidateSingleParameterSchema( schema *base.Schema, rawObject any, @@ -38,50 +38,46 @@ func ValidateSingleParameterSchema( operation string, ) (validationErrors []*errors.ValidationError) { var jsch *jsonschema.Schema - var jsonSchema []byte + var referenceSchema string // Try cache lookup first - avoids expensive schema compilation on each request if o != nil && o.SchemaCache != nil && schema != nil && schema.GoLow() != nil { - hash := schema.GoLow().Hash() + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + parameterSchemaVersion, + schema_validation.SchemaValidationPurposeGeneric, + ) if cached, ok := o.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil { jsch = cached.CompiledSchema + referenceSchema = cached.ReferenceSchema } } // Cache miss - compile the schema if jsch == nil { - // Get the JSON Schema for the parameter definition. - var err error - jsonSchema, err = buildJsonRender(schema) + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + schema_validation.SchemaValidationPurposeGeneric, + o, + parameterSchemaVersion, + ) if err != nil { return validationErrors } - - // Attempt to compile the JSON Schema - jsch, err = helpers.NewCompiledSchema(name, jsonSchema, o) - if err != nil { + if compiled == nil || compiled.CompiledSchema == nil { return validationErrors } + jsch = compiled.CompiledSchema + referenceSchema = compiled.ReferenceSchema // Store in cache for future requests if o != nil && o.SchemaCache != nil && schema != nil && schema.GoLow() != nil { - hash := schema.GoLow().Hash() - - renderCtx := base.NewInlineRenderContextForValidation() - renderedInline, _ := schema.RenderInlineWithContext(renderCtx) - referenceSchema := string(renderedInline) - - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedInline, &renderedNode) - - o.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: schema, - RenderedInline: renderedInline, - ReferenceSchema: referenceSchema, - RenderedJSON: jsonSchema, - CompiledSchema: jsch, - RenderedNode: &renderedNode, - }) + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + parameterSchemaVersion, + schema_validation.SchemaValidationPurposeGeneric, + ) + o.SchemaCache.Store(hash, compiled.ToCacheEntry(schema)) } } @@ -89,26 +85,14 @@ func ValidateSingleParameterSchema( scErrs := jsch.Validate(rawObject) var werras *jsonschema.ValidationError if stdError.As(scErrs, &werras) { - validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType, pathTemplate, operation) + validationErrors = formatJsonSchemaValidationError( + schema, werras, entity, reasonEntity, name, + validationType, subValType, pathTemplate, operation, referenceSchema, + ) } return validationErrors } -// buildJsonRender build a JSON render of the schema. -func buildJsonRender(schema *base.Schema) ([]byte, error) { - if schema == nil { - // Sanity Check - return nil, stdError.New("buildJSONRender nil pointer") - } - - renderedSchema, err := schema.Render() - if err != nil { - return nil, err - } - - return utils.ConvertYAMLtoJSON(renderedSchema) -} - // GetRenderedSchema returns a YAML string representation of the schema for error messages. // It first checks the schema cache for a pre-rendered version, falling back to fresh rendering. // This avoids expensive re-rendering on each validation when the cache is available. @@ -155,27 +139,29 @@ func ValidateParameterSchema( ) []*errors.ValidationError { var validationErrors []*errors.ValidationError var jsch *jsonschema.Schema - var jsonSchema []byte + var referenceSchema string // Try cache lookup first - avoids expensive schema compilation on each request if validationOptions != nil && validationOptions.SchemaCache != nil && schema != nil && schema.GoLow() != nil { - hash := schema.GoLow().Hash() + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + parameterSchemaVersion, + schema_validation.SchemaValidationPurposeGeneric, + ) if cached, ok := validationOptions.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil { jsch = cached.CompiledSchema + referenceSchema = cached.ReferenceSchema } } // Cache miss - render and compile the schema if jsch == nil { - // 1. build a JSON render of the schema. - renderCtx := base.NewInlineRenderContextForValidation() - renderedSchema, _ := schema.RenderInlineWithContext(renderCtx) - referenceSchema := string(renderedSchema) - jsonSchema, _ = utils.ConvertYAMLtoJSON(renderedSchema) - - // 2. create a new json schema compiler and add the schema to it - var err error - jsch, err = helpers.NewCompiledSchema(name, jsonSchema, validationOptions) + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + schema_validation.SchemaValidationPurposeGeneric, + validationOptions, + parameterSchemaVersion, + ) if err != nil { // schema compilation failed, return validation error instead of panicking validationErrors = append(validationErrors, &errors.ValidationError{ @@ -188,26 +174,24 @@ func ValidateParameterSchema( SpecCol: 0, ParameterName: name, HowToFix: "check the parameter schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs", - Context: string(jsonSchema), + Context: schema, }) return validationErrors } + if compiled == nil || compiled.CompiledSchema == nil { + return validationErrors + } + jsch = compiled.CompiledSchema + referenceSchema = compiled.ReferenceSchema // Store in cache for future requests if validationOptions != nil && validationOptions.SchemaCache != nil && schema != nil && schema.GoLow() != nil { - hash := schema.GoLow().Hash() - - var renderedNode yaml.Node - _ = yaml.Unmarshal(renderedSchema, &renderedNode) - - validationOptions.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: schema, - RenderedInline: renderedSchema, - ReferenceSchema: referenceSchema, - RenderedJSON: jsonSchema, - CompiledSchema: jsch, - RenderedNode: &renderedNode, - }) + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + parameterSchemaVersion, + schema_validation.SchemaValidationPurposeGeneric, + ) + validationOptions.SchemaCache.Store(hash, compiled.ToCacheEntry(schema)) } } @@ -278,7 +262,10 @@ func ValidateParameterSchema( } var werras *jsonschema.ValidationError if stdError.As(scErrs, &werras) { - validationErrors = formatJsonSchemaValidationError(schema, werras, entity, reasonEntity, name, validationType, subValType, "", "") + validationErrors = formatJsonSchemaValidationError( + schema, werras, entity, reasonEntity, name, + validationType, subValType, "", "", referenceSchema, + ) } // if there are no validationErrors, check that the supplied value is even JSON @@ -302,9 +289,20 @@ func ValidateParameterSchema( return validationErrors } -func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.ValidationError, entity string, reasonEntity string, name string, validationType string, subValType string, pathTemplate string, operation string) (validationErrors []*errors.ValidationError) { +func formatJsonSchemaValidationError( + schema *base.Schema, + scErrs *jsonschema.ValidationError, + entity string, + reasonEntity string, + name string, + validationType string, + subValType string, + pathTemplate string, + operation string, + referenceSchema string, +) (validationErrors []*errors.ValidationError) { // flatten the validationErrors - schFlatErrs := scErrs.BasicOutput().Errors + schFlatErrs := helpers.FlattenSchemaOutputErrors(scErrs.DetailedOutput()) var schemaValidationErrors []*errors.SchemaValidationFailure for q := range schFlatErrs { er := schFlatErrs[q] @@ -330,7 +328,9 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val KeywordLocation: keywordLocation, OriginalJsonSchemaError: scErrs, } - if schema != nil { + if referenceSchema != "" { + fail.ReferenceSchema = referenceSchema + } else if schema != nil { renderCtx := base.NewInlineRenderContextForValidation() rendered, err := schema.RenderInlineWithContext(renderCtx) if err == nil && rendered != nil { diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index f3585473..7cd842fd 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -2,11 +2,14 @@ package parameters import ( "net/http" + "os" + "path/filepath" "strings" "sync" "testing" "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/high/base" lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/testify/assert" @@ -14,6 +17,7 @@ import ( "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" + liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" ) @@ -25,6 +29,153 @@ func Test_ForceCompilerError(t *testing.T) { require.Empty(t, result) } +func TestValidateParameterSchema_CircularReferenceWithCacheDisabled(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /things: + get: + parameters: + - name: filter + in: query + schema: + $ref: '#/components/schemas/Node' + responses: + '200': + description: ok +components: + schemas: + Node: + type: object + properties: + id: + type: string + child: + $ref: '#/components/schemas/Node' +`) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Paths.PathItems.GetOrZero("/things").Get.Parameters[0].Schema.Schema() + opts := config.NewValidationOptions(config.WithSchemaCache(nil)) + + validationErrors := ValidateParameterSchema( + schema, + map[string]interface{}{ + "id": "root", + "child": map[string]interface{}{ + "id": "leaf", + }, + }, + "", + "query", + "query parameter", + "filter", + helpers.ParameterValidation, + helpers.Query, + opts, + ) + assert.Empty(t, validationErrors) + + validationErrors = ValidateParameterSchema( + schema, + map[string]interface{}{ + "id": "root", + "child": map[string]interface{}{ + "id": 42, + }, + }, + "", + "query", + "query parameter", + "filter", + helpers.ParameterValidation, + helpers.Query, + opts, + ) + + require.Len(t, validationErrors, 1) + requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") +} + +func TestValidateParameterSchema_ExternalReferenceWithCacheDisabled(t *testing.T) { + tempDir := t.TempDir() + rootPath := filepath.Join(tempDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /things: + get: + parameters: + - name: filter + in: query + schema: + $ref: './models.yaml#/components/schemas/Filter' + responses: + '200': + description: ok`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "models.yaml"), []byte(`components: + schemas: + Filter: + type: object + properties: + id: + type: string`), 0o600)) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = rootPath + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml"} + + rootSpec, err := os.ReadFile(rootPath) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Paths.PathItems.GetOrZero("/things").Get.Parameters[0].Schema.Schema() + validationErrors := ValidateParameterSchema( + schema, + map[string]interface{}{ + "id": 42, + }, + "", + "query", + "query parameter", + "filter", + helpers.ParameterValidation, + helpers.Query, + config.NewValidationOptions(config.WithSchemaCache(nil)), + ) + + require.Len(t, validationErrors, 1) + requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") +} + +func requireParameterFailureContaining( + t *testing.T, + failures []*liberrors.SchemaValidationFailure, + expectedReason string, +) *liberrors.SchemaValidationFailure { + t.Helper() + for _, failure := range failures { + if failure != nil && strings.Contains(failure.Reason, expectedReason) { + return failure + } + } + require.Failf(t, "schema failure not found", "expected reason containing %q", expectedReason) + return nil +} + func TestHeaderSchemaNoType(t *testing.T) { bytes := []byte(`{ "openapi": "3.0.0", diff --git a/requests/validate_request.go b/requests/validate_request.go index 66d3cc92..2dc1810a 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -20,7 +20,6 @@ import ( "golang.org/x/text/language" "golang.org/x/text/message" - "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" @@ -140,6 +139,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror var referenceSchema string var compiledSchema *jsonschema.Schema var cachedNode *yaml.Node + var resourceNodes map[string]*yaml.Node if input.Schema == nil { return false, []*liberrors.ValidationError{{ @@ -169,54 +169,15 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror jsonSchema = cached.RenderedJSON compiledSchema = cached.CompiledSchema cachedNode = cached.RenderedNode + resourceNodes = cached.ResourceNodes } } // Cache miss or no cache - render and compile if compiledSchema == nil { - rendered, renderErr := schema_validation.RenderSchemaForValidation( + compiled, err := schema_validation.CompileSchemaForValidation( input.Schema, schema_validation.SchemaValidationPurposeRequestBody, - ) - if rendered != nil { - renderedSchema = rendered.RenderedInline - referenceSchema = rendered.ReferenceSchema - jsonSchema = rendered.RenderedJSON - cachedNode = rendered.RenderedNode - } - - // If rendering failed (e.g., circular reference), return the render error - if renderErr != nil { - violation := &liberrors.SchemaValidationFailure{ - Reason: renderErr.Error(), - ReferenceSchema: referenceSchema, - } - validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: helpers.RequestBodyValidation, - ValidationSubType: helpers.Schema, - Message: fmt.Sprintf("%s request body for '%s' failed schema rendering", - input.Request.Method, input.Request.URL.Path), - Reason: fmt.Sprintf("The request schema failed to render: %s", - renderErr.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: liberrors.HowToFixInvalidRenderedSchema, - Context: referenceSchema, - }) - return false, validationErrors - } - - var err error - hash := schema_validation.SchemaCacheKey( - input.Schema.GoLow().Hash(), - input.Version, - schema_validation.SchemaValidationPurposeRequestBody, - ) - schemaName := fmt.Sprintf("%x", hash) - compiledSchema, err = helpers.NewCompiledSchemaWithVersion( - schemaName, - jsonSchema, validationOptions, input.Version, ) @@ -234,16 +195,20 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror }) return false, validationErrors } + renderedSchema = compiled.RenderedInline + referenceSchema = compiled.ReferenceSchema + jsonSchema = compiled.RenderedJSON + cachedNode = compiled.RenderedNode + resourceNodes = compiled.ResourceNodes + compiledSchema = compiled.CompiledSchema if validationOptions.SchemaCache != nil { - validationOptions.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: input.Schema, - RenderedInline: renderedSchema, - ReferenceSchema: referenceSchema, - RenderedJSON: jsonSchema, - CompiledSchema: compiledSchema, - RenderedNode: cachedNode, - }) + hash := schema_validation.SchemaCacheKey( + input.Schema.GoLow().Hash(), + input.Version, + schema_validation.SchemaValidationPurposeRequestBody, + ) + validationOptions.SchemaCache.Store(hash, compiled.ToCacheEntry(input.Schema)) } } @@ -316,7 +281,7 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror if errors.As(scErrs, &jk) { // flatten the validationErrors - schFlatErrs := jk.BasicOutput().Errors + schFlatErrs := helpers.FlattenSchemaOutputErrors(jk.DetailedOutput()) // Use cached node if available, otherwise parse renderedNode := cachedNode @@ -336,8 +301,13 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*liberror // locate the violated property in the schema var located *yaml.Node - if len(renderedNode.Content) > 0 { - located = schema_validation.LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation) + if renderedNode != nil { + located = schema_validation.LocateSchemaPropertyNodeByJSONPathWithResources( + renderedNode, + resourceNodes, + er.KeywordLocation, + er.AbsoluteKeywordLocation, + ) } // extract the element specified by the instance diff --git a/requests/validate_request_test.go b/requests/validate_request_test.go index 0394c6f4..6946b23f 100644 --- a/requests/validate_request_test.go +++ b/requests/validate_request_test.go @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package requests import ( @@ -5,15 +8,19 @@ import ( "io" "net/http" "net/url" + "os" + "path/filepath" "strings" "testing" "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" "github.com/pb33f/libopenapi-validator/config" + liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/schema_validation" ) @@ -374,7 +381,6 @@ func indentLines(s string, indent string) string { } func TestValidateRequestSchema_CircularReference(t *testing.T) { - // Test when schema has a circular reference that causes render failure spec := `openapi: 3.1.0 info: title: Test @@ -408,10 +414,194 @@ components: Version: 3.1, }) + assert.True(t, valid) + assert.Empty(t, errors) + + valid, errors = ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(`{"code": "abc", "details": [{"code": 42}]}`), + Schema: schema.Schema(), + Version: 3.1, + }) + assert.False(t, valid) require.Len(t, errors, 1) - assert.Contains(t, errors[0].Message, "failed schema rendering") - assert.Contains(t, errors[0].Reason, "circular reference") + require.NotEmpty(t, errors[0].SchemaValidationErrors) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "got number") +} + +func TestValidateRequestSchema_MultiFileComplexCircularReferences(t *testing.T) { + tempDir := t.TempDir() + + files := map[string]string{ + "openapi.yaml": `openapi: 3.1.0 +info: + title: Multi-file circular refs + version: 1.0.0 +paths: + /catalogs: + post: + requestBody: + content: + application/json: + schema: + $ref: './models.yaml#/components/schemas/Catalog'`, + "models.yaml": `components: + schemas: + Catalog: + type: object + required: [products, featured] + properties: + products: + type: array + minItems: 1 + items: + $ref: './product.yaml#/components/schemas/Product' + featured: + $ref: './product.yaml#/components/schemas/Product'`, + "product.yaml": `components: + schemas: + Product: + type: object + required: [sku, name, children, variants] + properties: + sku: + type: string + name: + type: string + children: + type: array + items: + $ref: '#/components/schemas/Product' + variants: + type: array + items: + $ref: './variant.yaml#/components/schemas/Variant'`, + "variant.yaml": `components: + schemas: + Variant: + type: object + required: [code, parent] + properties: + code: + type: string + parent: + $ref: './product.yaml#/components/schemas/Product' + alternatives: + type: array + items: + $ref: '#/components/schemas/Variant'`, + } + + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0o600)) + } + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = filepath.Join(tempDir, "openapi.yaml") + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml", "product.yaml", "variant.yaml"} + docConfig.SkipCircularReferenceCheck = true + + rootSpec, err := os.ReadFile(filepath.Join(tempDir, "openapi.yaml")) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Paths.PathItems.GetOrZero("/catalogs").Post.RequestBody.Content.GetOrZero("application/json").Schema + require.NotNil(t, schema) + + validPayload := `{ + "products": [ + { + "sku": "root", + "name": "Root", + "children": [ + { + "sku": "child", + "name": "Child", + "children": [], + "variants": [] + } + ], + "variants": [ + { + "code": "red", + "parent": { + "sku": "parent", + "name": "Parent", + "children": [], + "variants": [] + }, + "alternatives": [] + } + ] + } + ], + "featured": { + "sku": "featured", + "name": "Featured", + "children": [], + "variants": [] + } +}` + + invalidPayload := strings.Replace(validPayload, `"code": "red"`, `"code": 42`, 1) + valid, validationErrors := ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(invalidPayload), + Schema: schema.Schema(), + Version: 3.1, + Options: []config.Option{ + config.WithSchemaCache(nil), + }, + }) + + assert.False(t, valid) + require.Len(t, validationErrors, 1) + coldFailure := requireSchemaFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + assert.Equal(t, "code", coldFailure.FieldName) + assert.Equal(t, 8, coldFailure.Line) + assert.Greater(t, coldFailure.Column, 0) + assert.Contains(t, coldFailure.KeywordLocation, "/type") + + valid, validationErrors = ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(validPayload), + Schema: schema.Schema(), + Version: 3.1, + }) + + assert.True(t, valid) + assert.Empty(t, validationErrors) + + valid, validationErrors = ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(invalidPayload), + Schema: schema.Schema(), + Version: 3.1, + }) + + assert.False(t, valid) + require.Len(t, validationErrors, 1) + require.NotEmpty(t, validationErrors[0].SchemaValidationErrors) + cachedFailure := requireSchemaFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + assert.Equal(t, coldFailure.Line, cachedFailure.Line) + assert.Equal(t, coldFailure.Column, cachedFailure.Column) +} + +func requireSchemaFailureContaining( + t *testing.T, + failures []*liberrors.SchemaValidationFailure, + expectedReason string, +) *liberrors.SchemaValidationFailure { + t.Helper() + for _, failure := range failures { + if failure != nil && strings.Contains(failure.Reason, expectedReason) { + return failure + } + } + require.Failf(t, "schema failure not found", "expected reason containing %q", expectedReason) + return nil } func TestValidateRequestSchema_NilParentProxy(t *testing.T) { diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index 1e11b3a8..8de2675b 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1266,21 +1266,14 @@ components: func(w http.ResponseWriter, r *http.Request) { w.Header().Set(helpers.ContentTypeHeader, helpers.JSONContentType) w.WriteHeader(http.StatusOK) - _, _ = w.Write(nil) + _, _ = w.Write([]byte(`{"code":"abc","details":[{"code":"def"}]}`)) }, ) valid, errors := tb.responseBodyValidator.ValidateResponseBody(req, res) - assert.False(t, valid) - assert.Len(t, errors, 1) - // The error message may vary depending on whether the circular reference is caught - // during rendering or compilation, so we check for either pattern - assert.True(t, - strings.Contains(errors[0].Reason, "circular reference") || - strings.Contains(errors[0].Reason, "json-pointer") || - strings.Contains(errors[0].Reason, "not found"), - "Expected error about circular reference or JSON pointer not found, got: %s", errors[0].Reason) + assert.True(t, valid) + assert.Empty(t, errors) } func TestValidateResponseBody_XMLMarshalError(t *testing.T) { diff --git a/responses/validate_response.go b/responses/validate_response.go index c57f10df..5503c033 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -20,7 +20,6 @@ import ( "golang.org/x/text/language" "golang.org/x/text/message" - "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" liberrors "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" @@ -48,10 +47,11 @@ type ValidateResponseSchemaInput struct { func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberrors.ValidationError) { validationOptions := config.NewValidationOptions(input.Options...) var validationErrors []*liberrors.ValidationError - var renderedSchema, jsonSchema []byte + var renderedSchema []byte var referenceSchema string var compiledSchema *jsonschema.Schema var cachedNode *yaml.Node + var resourceNodes map[string]*yaml.Node if input.Schema == nil { return false, []*liberrors.ValidationError{{ @@ -78,57 +78,17 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr if cached, ok := validationOptions.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil { renderedSchema = cached.RenderedInline referenceSchema = cached.ReferenceSchema - jsonSchema = cached.RenderedJSON compiledSchema = cached.CompiledSchema cachedNode = cached.RenderedNode + resourceNodes = cached.ResourceNodes } } // Cache miss or no cache - render and compile if compiledSchema == nil { - rendered, renderErr := schema_validation.RenderSchemaForValidation( + compiled, err := schema_validation.CompileSchemaForValidation( input.Schema, schema_validation.SchemaValidationPurposeResponseBody, - ) - if rendered != nil { - renderedSchema = rendered.RenderedInline - referenceSchema = rendered.ReferenceSchema - jsonSchema = rendered.RenderedJSON - cachedNode = rendered.RenderedNode - } - - // If rendering failed (e.g., circular reference), return the render error - if renderErr != nil { - violation := &liberrors.SchemaValidationFailure{ - Reason: renderErr.Error(), - ReferenceSchema: referenceSchema, - } - validationErrors = append(validationErrors, &liberrors.ValidationError{ - ValidationType: helpers.ResponseBodyValidation, - ValidationSubType: helpers.Schema, - Message: fmt.Sprintf("%d response body for '%s' failed schema rendering", - input.Response.StatusCode, input.Request.URL.Path), - Reason: fmt.Sprintf("The response schema for status code '%d' failed to render: %s", - input.Response.StatusCode, renderErr.Error()), - SpecLine: 1, - SpecCol: 0, - SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation}, - HowToFix: "check the response schema for circular references or invalid structures", - Context: referenceSchema, - }) - return false, validationErrors - } - - var err error - hash := schema_validation.SchemaCacheKey( - input.Schema.GoLow().Hash(), - input.Version, - schema_validation.SchemaValidationPurposeResponseBody, - ) - schemaName := fmt.Sprintf("%x", hash) - compiledSchema, err = helpers.NewCompiledSchemaWithVersion( - schemaName, - jsonSchema, validationOptions, input.Version, ) @@ -147,16 +107,19 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr }) return false, validationErrors } + renderedSchema = compiled.RenderedInline + referenceSchema = compiled.ReferenceSchema + cachedNode = compiled.RenderedNode + resourceNodes = compiled.ResourceNodes + compiledSchema = compiled.CompiledSchema if validationOptions.SchemaCache != nil { - validationOptions.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: input.Schema, - RenderedInline: renderedSchema, - ReferenceSchema: referenceSchema, - RenderedJSON: jsonSchema, - CompiledSchema: compiledSchema, - RenderedNode: cachedNode, - }) + hash := schema_validation.SchemaCacheKey( + input.Schema.GoLow().Hash(), + input.Version, + schema_validation.SchemaValidationPurposeResponseBody, + ) + validationOptions.SchemaCache.Store(hash, compiled.ToCacheEntry(input.Schema)) } } @@ -261,7 +224,7 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr if errors.As(scErrs, &jk) { // flatten the validationErrors - schFlatErrs := jk.BasicOutput().Errors + schFlatErrs := helpers.FlattenSchemaOutputErrors(jk.DetailedOutput()) renderedNode := cachedNode if renderedNode == nil { @@ -279,8 +242,13 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberr if er.Error != nil { // locate the violated property in the schema var located *yaml.Node - if len(renderedNode.Content) > 0 { - located = schema_validation.LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation) + if renderedNode != nil { + located = schema_validation.LocateSchemaPropertyNodeByJSONPathWithResources( + renderedNode, + resourceNodes, + er.KeywordLocation, + er.AbsoluteKeywordLocation, + ) } // extract the element specified by the instance diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go index 3f54b662..a1ae5ca7 100644 --- a/responses/validate_response_test.go +++ b/responses/validate_response_test.go @@ -350,7 +350,6 @@ func TestValidateResponseSchema_NilSchemaGoLow(t *testing.T) { } func TestValidateResponseSchema_CircularReference(t *testing.T) { - // Test when schema has a circular reference that causes render failure spec := `openapi: 3.1.0 info: title: Test @@ -385,10 +384,20 @@ components: Version: 3.1, }) + assert.True(t, valid) + assert.Empty(t, errors) + + valid, errors = ValidateResponseSchema(&ValidateResponseSchemaInput{ + Request: postRequest(), + Response: responseWithBody(`{"code": "abc", "details": [{"code": 42}]}`), + Schema: schema.Schema(), + Version: 3.1, + }) + assert.False(t, valid) require.Len(t, errors, 1) - assert.Contains(t, errors[0].Message, "failed schema rendering") - assert.Contains(t, errors[0].Reason, "circular reference") + require.NotEmpty(t, errors[0].SchemaValidationErrors) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "got number") } func TestValidateResponseSchema_ResponseMissing(t *testing.T) { diff --git a/schema_validation/locate_schema_property.go b/schema_validation/locate_schema_property.go index a42d6211..12d00207 100644 --- a/schema_validation/locate_schema_property.go +++ b/schema_validation/locate_schema_property.go @@ -1,9 +1,12 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package schema_validation import ( + "net/url" + "strings" + "github.com/pb33f/jsonpath/pkg/jsonpath" "github.com/pb33f/jsonpath/pkg/jsonpath/config" "github.com/pb33f/libopenapi/utils" @@ -13,10 +16,94 @@ import ( // LocateSchemaPropertyNodeByJSONPath will locate a schema property node by a JSONPath. It converts something like // #/components/schemas/MySchema/properties/MyProperty to something like $.components.schemas.MySchema.properties.MyProperty func LocateSchemaPropertyNodeByJSONPath(doc *yaml.Node, JSONPath string) *yaml.Node { + JSONPath = normalizeKeywordLocation(JSONPath) _, path := utils.ConvertComponentIdIntoFriendlyPathSearch(JSONPath) return locateSchemaPropertyNode(doc, path) } +// LocateSchemaPropertyNodeByJSONPathFallback locates a schema node using a primary and fallback keyword location. +func LocateSchemaPropertyNodeByJSONPathFallback(doc *yaml.Node, primaryLocation, fallbackLocation string) *yaml.Node { + return LocateSchemaPropertyNodeByJSONPathWithResources(doc, nil, primaryLocation, fallbackLocation) +} + +// LocateSchemaPropertyNodeByJSONPathWithResources locates a schema node, selecting external resource nodes when available. +func LocateSchemaPropertyNodeByJSONPathWithResources( + doc *yaml.Node, + resourceNodes map[string]*yaml.Node, + primaryLocation, fallbackLocation string, +) *yaml.Node { + located := locateSchemaPropertyNodeByKeywordLocation(doc, resourceNodes, primaryLocation) + if located != nil || fallbackLocation == "" { + return located + } + return locateSchemaPropertyNodeByKeywordLocation(doc, resourceNodes, fallbackLocation) +} + +func normalizeKeywordLocation(location string) string { + _, pointer := splitKeywordLocation(location) + return pointer +} + +func locateSchemaPropertyNodeByKeywordLocation( + doc *yaml.Node, + resourceNodes map[string]*yaml.Node, + location string, +) *yaml.Node { + resourceName, pointer := splitKeywordLocation(location) + sourceNode := doc + if resourceName != "" && resourceNodes != nil { + if resourceNode := lookupResourceNode(resourceNodes, resourceName); resourceNode != nil { + sourceNode = resourceNode + } + } + return LocateSchemaPropertyNodeByJSONPath(rootContentNode(sourceNode), pointer) +} + +func lookupResourceNode(resourceNodes map[string]*yaml.Node, resourceName string) *yaml.Node { + if resourceNode := resourceNodes[resourceName]; resourceNode != nil { + return resourceNode + } + if !strings.HasPrefix(resourceName, "file:") { + return resourceNodes[canonicalResourceName(resourceName)] + } + + parsedURL, err := url.Parse(resourceName) + if err != nil || parsedURL.Scheme != "file" || parsedURL.Path == "" { + return nil + } + + if resourceNode := resourceNodes[parsedURL.String()]; resourceNode != nil { + return resourceNode + } + filePath, err := url.PathUnescape(parsedURL.Path) + if err != nil { + filePath = parsedURL.Path + } + if resourceNode := resourceNodes[filePath]; resourceNode != nil { + return resourceNode + } + return resourceNodes[canonicalResourceName(filePath)] +} + +func splitKeywordLocation(location string) (string, string) { + if location == "" || strings.HasPrefix(location, "#") || strings.HasPrefix(location, "/") { + return "", location + } + + hashIndex := strings.Index(location, "#") + if hashIndex < 0 { + return "", location + } + return location[:hashIndex], location[hashIndex:] +} + +func rootContentNode(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + func locateSchemaPropertyNode(doc *yaml.Node, path string) *yaml.Node { if path == "" { return nil diff --git a/schema_validation/locate_schema_property_test.go b/schema_validation/locate_schema_property_test.go index 452c1b7a..f10efd9a 100644 --- a/schema_validation/locate_schema_property_test.go +++ b/schema_validation/locate_schema_property_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package schema_validation @@ -7,6 +7,8 @@ import ( "testing" "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" ) func TestLocateSchemaPropertyNodeByJSONPath_BadNode(t *testing.T) { @@ -16,3 +18,99 @@ func TestLocateSchemaPropertyNodeByJSONPath_BadNode(t *testing.T) { func TestLocateSchemaPropertyNode_EmptyPath(t *testing.T) { assert.Nil(t, locateSchemaPropertyNode(nil, "")) } + +func TestLocateSchemaPropertyNodeByJSONPathFallback_AbsoluteKeywordLocation(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Pet: + type: object + properties: + id: + type: integer`), &root)) + + located := LocateSchemaPropertyNodeByJSONPathFallback( + root.Content[0], + "/items/$ref/properties/id/type", + "https://libopenapi-validator.local/schema/root.json#/components/schemas/Pet/properties/id/type", + ) + + require.NotNil(t, located) + assert.Equal(t, "integer", located.Value) +} + +func TestLocateSchemaPropertyNodeByJSONPath_PreservesHashInLocalPointerNames(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Model#v1#beta: + type: object + properties: + id#value: + type: string`), &root)) + + located := LocateSchemaPropertyNodeByJSONPath( + root.Content[0], + "/components/schemas/Model#v1#beta/properties/id#value/type", + ) + + require.NotNil(t, located) + assert.Equal(t, "string", located.Value) +} + +func TestLocateSchemaPropertyNodeByJSONPathWithResources_UsesExternalResourceNode(t *testing.T) { + var entry yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Entry: + $ref: './models.yaml#/components/schemas/Model#v1#beta'`), &entry)) + + var external yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Model#v1#beta: + type: object + properties: + id: + type: integer`), &external)) + + located := LocateSchemaPropertyNodeByJSONPathWithResources( + entry.Content[0], + map[string]*yaml.Node{"/tmp/models.yaml": &external}, + "/components/schemas/Entry/$ref/properties/id/type", + "file:///tmp/models.yaml#/components/schemas/Model#v1#beta/properties/id/type", + ) + + require.NotNil(t, located) + assert.Equal(t, "integer", located.Value) +} + +func TestSplitKeywordLocation_DoesNotTreatLocalPointerHashAsResource(t *testing.T) { + resourceName, pointer := splitKeywordLocation("/components/schemas/Model#v1#beta/properties/id/type") + + assert.Empty(t, resourceName) + assert.Equal(t, "/components/schemas/Model#v1#beta/properties/id/type", pointer) + + resourceName, pointer = splitKeywordLocation("https://example.com/models.yaml#/components/schemas/Model#v1#beta") + + assert.Equal(t, "https://example.com/models.yaml", resourceName) + assert.Equal(t, "#/components/schemas/Model#v1#beta", pointer) + + resourceName, pointer = splitKeywordLocation("schema") + + assert.Empty(t, resourceName) + assert.Equal(t, "schema", pointer) +} + +func TestLookupResourceNode_EdgeCases(t *testing.T) { + assert.Nil(t, lookupResourceNode(nil, "https://example.com/models.yaml")) + assert.Nil(t, lookupResourceNode(nil, "file:///tmp/%zz")) + + escapedPathNode := &yaml.Node{Kind: yaml.MappingNode} + located := lookupResourceNode( + map[string]*yaml.Node{"/tmp/models with space.yaml": escapedPathNode}, + "file:///tmp/models%20with%20space.yaml", + ) + + assert.Same(t, escapedPathNode, located) +} diff --git a/schema_validation/schema_resources.go b/schema_validation/schema_resources.go new file mode 100644 index 00000000..6ddfc615 --- /dev/null +++ b/schema_validation/schema_resources.go @@ -0,0 +1,602 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/utils" + "github.com/santhosh-tekuri/jsonschema/v6" + "go.yaml.in/yaml/v4" + + "github.com/pb33f/libopenapi-validator/config" + "github.com/pb33f/libopenapi-validator/helpers" +) + +const syntheticSchemaResourceBase = "https://libopenapi-validator.local/schema/" + +// CompiledValidationSchema contains a schema compiled for validation plus the rendered schema context. +type CompiledValidationSchema struct { + RenderedInline []byte + ReferenceSchema string + RenderedJSON []byte + RenderedNode *yaml.Node + ResourceNodes map[string]*yaml.Node + CompiledSchema *jsonschema.Schema +} + +// schemaDocumentResourceSet is the in-memory set of JSON Schema resources passed to jsonschema. +type schemaDocumentResourceSet struct { + resources map[string][]byte + resourceNodes map[string]*yaml.Node + entryName string + entryNode *yaml.Node +} + +// schemaResourceBuildState tracks resource identity and recursion guards while walking reachable refs. +type schemaResourceBuildState struct { + resourceNames map[*index.SpecIndex]string + seenRefs map[string]struct{} + seenNodes map[*yaml.Node]struct{} +} + +// CompileSchemaForValidation compiles a schema for validation while preserving reachable references. +func CompileSchemaForValidation( + schema *base.Schema, + purpose SchemaValidationPurpose, + options *config.ValidationOptions, + version float32, +) (*CompiledValidationSchema, error) { + if schema == nil { + return nil, nil + } + + rendered, err := renderRootSchemaForValidation(schema, purpose) + if err != nil { + return nil, err + } + + if singleSchemaCompilePreferred(schema, rendered) { + return compileSingleValidationSchema(schema, rendered, options, version) + } + + resourceSet, err := buildSchemaDocumentResources(schema, purpose) + if err != nil { + return nil, err + } + + if resourceSet == nil || len(resourceSet.resources) == 0 || resourceSet.entryName == "" { + return compileSingleValidationSchema(schema, rendered, options, version) + } + + compiled, compileErr := helpers.NewCompiledSchemaResourcesWithVersion( + resourceSet.entryName, + resourceSet.resources, + options, + version, + ) + if compileErr != nil { + return nil, compileErr + } + + return &CompiledValidationSchema{ + RenderedInline: rendered.RenderedInline, + ReferenceSchema: rendered.ReferenceSchema, + RenderedJSON: rendered.RenderedJSON, + RenderedNode: resourceSet.entryNode, + ResourceNodes: resourceSet.resourceNodes, + CompiledSchema: compiled, + }, nil +} + +// ToCacheEntry converts a compiled validation schema into the shared schema cache shape. +func (c *CompiledValidationSchema) ToCacheEntry(schema *base.Schema) *cache.SchemaCacheEntry { + if c == nil { + return nil + } + return &cache.SchemaCacheEntry{ + Schema: schema, + RenderedInline: c.RenderedInline, + ReferenceSchema: c.ReferenceSchema, + RenderedJSON: c.RenderedJSON, + CompiledSchema: c.CompiledSchema, + RenderedNode: c.RenderedNode, + ResourceNodes: c.ResourceNodes, + } +} + +// compileSingleValidationSchema compiles one rendered schema document without building a resource graph. +// +// This keeps the no-ref path close to the legacy behavior and avoids the extra +// resource rendering work needed only when reachable refs must stay addressable. +func compileSingleValidationSchema( + schema *base.Schema, + rendered *RenderedValidationSchema, + options *config.ValidationOptions, + version float32, +) (*CompiledValidationSchema, error) { + schemaName := "schema" + if schema != nil && schema.GoLow() != nil { + schemaName = fmt.Sprintf("%x", schema.GoLow().Hash()) + } + compiled, compileErr := helpers.NewCompiledSchemaWithVersion( + schemaName, + rendered.RenderedJSON, + options, + version, + ) + if compileErr != nil { + return nil, compileErr + } + return &CompiledValidationSchema{ + RenderedInline: rendered.RenderedInline, + ReferenceSchema: rendered.ReferenceSchema, + RenderedJSON: rendered.RenderedJSON, + RenderedNode: rendered.RenderedNode, + ResourceNodes: sourceNodesForRenderedSchema(schemaName, rendered.RenderedNode), + CompiledSchema: compiled, + }, nil +} + +// sourceNodesForRenderedSchema builds the source-node lookup for the single-resource compiler path. +// +// The same rendered node is indexed by both the empty resource name and the +// compiler resource name because jsonschema diagnostics can identify the entry +// schema either way depending on the reported location shape. +func sourceNodesForRenderedSchema(resourceName string, renderedNode *yaml.Node) map[string]*yaml.Node { + if renderedNode == nil { + return nil + } + + resourceNodes := map[string]*yaml.Node{ + "": renderedNode, + } + if resourceName != "" { + resourceNodes[resourceName] = renderedNode + } + return resourceNodes +} + +// renderRootSchemaForValidation renders the entry schema in the best form available for validation. +// +// The normal renderer is preferred because it applies request/response pruning +// consistently. If inline rendering cannot resolve a circular graph, this falls +// back to rendering the schema node with refs intact so the resource compiler can +// preserve those refs. +func renderRootSchemaForValidation(schema *base.Schema, purpose SchemaValidationPurpose) (*RenderedValidationSchema, error) { + if schema == nil { + return nil, nil + } + if schema.GoLow() == nil || schema.GoLow().GetRootNode() == nil { + return nil, fmt.Errorf("schema does not have low-level information and cannot be rendered") + } + + rendered, err := RenderSchemaForValidation(schema, purpose) + if err == nil { + return rendered, nil + } + + renderedInline, err := schema.Render() + if err != nil { + return nil, err + } + + return renderSchemaBytesForValidation(renderedInline, purpose) +} + +// singleSchemaCompilePreferred reports whether the schema can safely use the one-resource compiler path. +// +// The decision is based on reachable schema-keyword refs in the source tree, not +// a rendered-output scan, so examples, property names, enum values, or const +// values that literally contain "$ref" do not force the slower resource graph. +func singleSchemaCompilePreferred(schema *base.Schema, rendered *RenderedValidationSchema) bool { + return rendered != nil && !schemaHasReachableRefs(schema) +} + +// schemaHasReachableRefs checks whether the entry schema tree contains refs that jsonschema must resolve. +func schemaHasReachableRefs(schema *base.Schema) bool { + if schema == nil || schema.GoLow() == nil { + return false + } + return len(collectSchemaRefValues(schema.GoLow().GetRootNode())) > 0 +} + +// renderYAMLNodeForValidation renders a YAML node to the YAML and JSON forms used by jsonschema. +// +// Request and response validation prune directional "required" markers, so they +// clone before mutating. Generic validation does not prune, which keeps the +// whole-document resource path allocation-light for ordinary document schemas. +func renderYAMLNodeForValidation(node *yaml.Node, purpose SchemaValidationPurpose) (*RenderedValidationSchema, error) { + if node == nil { + return nil, nil + } + + renderedNode := node + if purpose != SchemaValidationPurposeGeneric { + renderedNode = cloneYAMLNode(node) + pruneDirectionalRequiredEverywhere(renderedNode, purpose) + } + + renderedInline, err := yaml.Marshal(renderedNode) + if err != nil { + return nil, fmt.Errorf("schema render encode failed: %w", err) + } + + renderedJSON, err := utils.ConvertYAMLtoJSON(renderedInline) + if err != nil { + return nil, fmt.Errorf("schema render JSON conversion failed: %w", err) + } + + return &RenderedValidationSchema{ + RenderedInline: renderedInline, + ReferenceSchema: string(renderedInline), + RenderedJSON: renderedJSON, + RenderedNode: renderedNode, + }, nil +} + +// buildSchemaDocumentResources builds the resource graph needed for a schema with reachable refs. +// +// The root OpenAPI document is registered as a resource, then only documents +// reachable from schema-keyword refs are added. The entry name points at the +// specific schema node inside the root resource, not necessarily the document root. +func buildSchemaDocumentResources( + schema *base.Schema, + purpose SchemaValidationPurpose, +) (*schemaDocumentResourceSet, error) { + if schema == nil || schema.GoLow() == nil { + return nil, nil + } + + schemaIndex := schema.GoLow().GetIndex() + if schemaIndex == nil || schemaIndex.GetRootNode() == nil { + return nil, nil + } + if !schemaHasReachableRefs(schema) { + return nil, nil + } + + schemaPointer, ok := jsonPointerForNode(schemaIndex.GetRootNode(), schema.GoLow().GetRootNode()) + if !ok { + return nil, fmt.Errorf("schema node was not found in its root document") + } + + state := &schemaResourceBuildState{ + resourceNames: make(map[*index.SpecIndex]string), + seenRefs: make(map[string]struct{}), + seenNodes: make(map[*yaml.Node]struct{}), + } + rootResourceName := resourceNameForIndex(schemaIndex, schema.GoLow().Hash()) + state.resourceNames[schemaIndex] = rootResourceName + resourceSet := &schemaDocumentResourceSet{ + resources: make(map[string][]byte), + resourceNodes: make(map[string]*yaml.Node), + } + rootRendered, err := addSchemaDocumentResource( + resourceSet.resources, + resourceSet.resourceNodes, + rootResourceName, + schemaIndex.GetRootNode(), + purpose, + ) + if err != nil { + return nil, err + } + + if err := addReachableSchemaResources( + resourceSet, + state, + schemaIndex, + schema.GoLow().GetRootNode(), + purpose, + ); err != nil { + return nil, err + } + + resourceSet.entryName = rootResourceName + "#" + schemaPointer + if rootRendered != nil { + resourceSet.entryNode = rootRendered.RenderedNode + } + return resourceSet, nil +} + +// addReachableSchemaResources recursively adds resources referenced by schema-keyword refs. +// +// seenNodes prevents circular schema graphs from recursing forever, while +// seenRefs prevents repeatedly visiting the same resolved definition through +// different local paths. +func addReachableSchemaResources( + resourceSet *schemaDocumentResourceSet, + state *schemaResourceBuildState, + currentIndex *index.SpecIndex, + node *yaml.Node, + purpose SchemaValidationPurpose, +) error { + if resourceSet == nil || state == nil || currentIndex == nil || node == nil { + return nil + } + if _, seen := state.seenNodes[node]; seen { + return nil + } + state.seenNodes[node] = struct{}{} + + for _, refValue := range collectSchemaRefValues(node) { + foundRef, foundIndex := currentIndex.SearchIndexForReference(refValue) + if foundRef == nil { + continue + } + if foundIndex == nil { + foundIndex = foundRef.Index + } + if foundIndex == nil || foundIndex.GetRootNode() == nil { + continue + } + + resourceName := ensureSchemaResourceName(state, foundIndex, uint64(len(resourceSet.resources)+1)) + refKey := resourceName + "|" + foundRef.FullDefinition + if _, seen := state.seenRefs[refKey]; seen { + continue + } + state.seenRefs[refKey] = struct{}{} + + if _, exists := resourceSet.resources[resourceName]; !exists { + if _, err := addSchemaDocumentResource( + resourceSet.resources, + resourceSet.resourceNodes, + resourceName, + foundIndex.GetRootNode(), + purpose, + ); err != nil { + return err + } + } + + if err := addReachableSchemaResources(resourceSet, state, foundIndex, foundRef.Node, purpose); err != nil { + return err + } + } + return nil +} + +// ensureSchemaResourceName returns the stable compiler resource name for a parsed document. +func ensureSchemaResourceName(state *schemaResourceBuildState, schemaIndex *index.SpecIndex, fallback uint64) string { + if state == nil || schemaIndex == nil { + return "" + } + if resourceName, exists := state.resourceNames[schemaIndex]; exists { + return resourceName + } + resourceName := resourceNameForIndex(schemaIndex, fallback) + state.resourceNames[schemaIndex] = resourceName + return resourceName +} + +// addSchemaDocumentResource renders and registers a document-level JSON Schema resource. +func addSchemaDocumentResource( + resources map[string][]byte, + resourceNodes map[string]*yaml.Node, + resourceName string, + rootNode *yaml.Node, + purpose SchemaValidationPurpose, +) (*RenderedValidationSchema, error) { + if resourceName == "" || rootNode == nil { + return nil, nil + } + + rendered, err := renderYAMLNodeForValidation(rootNode, purpose) + if err != nil { + return nil, fmt.Errorf("schema resource %q render failed: %w", resourceName, err) + } + + if resources != nil { + resources[resourceName] = rendered.RenderedJSON + } + if resourceNodes != nil { + resourceNodes[resourceName] = rendered.RenderedNode + } + return rendered, nil +} + +// resourceNameForIndex returns the canonical resource URI for an indexed document. +// +// File-backed specs use a canonical file URI so jsonschema absolute keyword +// locations line up with source-node lookup. Memory-only specs get deterministic +// synthetic HTTPS names scoped to this validator package. +func resourceNameForIndex(schemaIndex *index.SpecIndex, fallback uint64) string { + if schemaIndex != nil && schemaIndex.GetSpecAbsolutePath() != "" { + return canonicalResourceName(schemaIndex.GetSpecAbsolutePath()) + } + return syntheticSchemaResourceBase + strconv.FormatUint(fallback, 16) + ".json" +} + +// canonicalResourceName normalizes resource names into the URI form used by jsonschema diagnostics. +func canonicalResourceName(resourceName string) string { + if resourceName == "" { + return "" + } + parsed, err := url.Parse(resourceName) + if err == nil && parsed.Scheme != "" { + return parsed.String() + } + absPath := resourceName + if !filepath.IsAbs(absPath) { + if resolved, err := filepath.Abs(absPath); err == nil { + absPath = resolved + } + } + return (&url.URL{Scheme: "file", Path: filepath.ToSlash(absPath)}).String() +} + +// collectSchemaRefValues returns refs from schema-keyword positions in a YAML tree. +func collectSchemaRefValues(node *yaml.Node) []string { + var refs []string + collectSchemaRefValuesInto(node, "", &refs) + return refs +} + +// collectSchemaRefValuesInto walks a YAML tree and records only real schema $ref keywords. +// +// OpenAPI schema maps can legally contain schema names or property names called +// "$ref"; those names are not references and must not pull unrelated resources +// into the compiler graph. +func collectSchemaRefValuesInto(node *yaml.Node, parentKey string, refs *[]string) { + if node == nil { + return + } + + switch node.Kind { + case yaml.DocumentNode: + for _, child := range node.Content { + collectSchemaRefValuesInto(child, parentKey, refs) + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + key := "" + if keyNode != nil { + key = keyNode.Value + } + if key == "$ref" && !isSchemaNameMap(parentKey) && valueNode != nil && valueNode.Kind == yaml.ScalarNode { + *refs = append(*refs, valueNode.Value) + continue + } + collectSchemaRefValuesInto(valueNode, key, refs) + } + case yaml.SequenceNode: + for _, child := range node.Content { + collectSchemaRefValuesInto(child, parentKey, refs) + } + } +} + +// isSchemaNameMap reports whether a mapping value is keyed by user-defined schema names. +func isSchemaNameMap(parentKey string) bool { + switch parentKey { + case "properties", "patternProperties", "$defs", "definitions", "dependentSchemas": + return true + default: + return false + } +} + +// cloneYAMLNode deep-copies a YAML node tree before validation-specific pruning mutates it. +func cloneYAMLNode(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + + cloned := *node + if len(node.Content) > 0 { + cloned.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + cloned.Content[i] = cloneYAMLNode(child) + } + } + return &cloned +} + +// pruneDirectionalRequiredEverywhere removes request-only or response-only required markers recursively. +func pruneDirectionalRequiredEverywhere(node *yaml.Node, purpose SchemaValidationPurpose) { + if node == nil { + return + } + if node.Kind == yaml.MappingNode { + pruneRequiredAtSchema(node, purpose) + } + for _, child := range node.Content { + pruneDirectionalRequiredEverywhere(child, purpose) + } +} + +// jsonPointerForNode returns the RFC 6901 pointer to targetNode relative to rootNode. +func jsonPointerForNode(rootNode, targetNode *yaml.Node) (string, bool) { + if rootNode == nil || targetNode == nil { + return "", false + } + if rootNode == targetNode { + return "", true + } + if rootNode.Kind == yaml.DocumentNode && len(rootNode.Content) > 0 { + return jsonPointerForNode(rootNode.Content[0], targetNode) + } + if targetNode.Kind == yaml.DocumentNode && len(targetNode.Content) > 0 { + return jsonPointerForNode(rootNode, targetNode.Content[0]) + } + + var tokens []string + ok := appendJSONPointerTokensForNode(rootNode, targetNode, &tokens) + if !ok { + return "", false + } + if len(tokens) == 0 { + return "", true + } + return "/" + strings.Join(tokens, "/"), true +} + +// jsonPointerTokensForNode returns the unjoined pointer tokens for targetNode relative to currentNode. +func jsonPointerTokensForNode(currentNode, targetNode *yaml.Node) ([]string, bool) { + var tokens []string + ok := appendJSONPointerTokensForNode(currentNode, targetNode, &tokens) + return tokens, ok +} + +// appendJSONPointerTokensForNode depth-first searches a YAML tree while maintaining the current pointer path. +// +// The path is pushed and popped in place so deep schemas avoid the O(depth^2) +// allocations that come from prepending tokens during recursive unwind. +func appendJSONPointerTokensForNode(currentNode, targetNode *yaml.Node, tokens *[]string) bool { + if currentNode == nil { + return false + } + if currentNode == targetNode { + return true + } + + switch currentNode.Kind { + case yaml.DocumentNode: + if len(currentNode.Content) == 0 { + return false + } + return appendJSONPointerTokensForNode(currentNode.Content[0], targetNode, tokens) + case yaml.MappingNode: + for i := 0; i+1 < len(currentNode.Content); i += 2 { + keyNode := currentNode.Content[i] + valueNode := currentNode.Content[i+1] + token := "" + if keyNode != nil { + token = escapeJSONPointerToken(keyNode.Value) + } + *tokens = append(*tokens, token) + if appendJSONPointerTokensForNode(valueNode, targetNode, tokens) { + return true + } + *tokens = (*tokens)[:len(*tokens)-1] + } + case yaml.SequenceNode: + for i, valueNode := range currentNode.Content { + *tokens = append(*tokens, strconv.Itoa(i)) + if appendJSONPointerTokensForNode(valueNode, targetNode, tokens) { + return true + } + *tokens = (*tokens)[:len(*tokens)-1] + } + } + + return false +} + +// escapeJSONPointerToken escapes a single token according to RFC 6901. +func escapeJSONPointerToken(token string) string { + token = strings.ReplaceAll(token, "~", "~0") + return strings.ReplaceAll(token, "/", "~1") +} diff --git a/schema_validation/schema_resources_test.go b/schema_validation/schema_resources_test.go new file mode 100644 index 00000000..4aaa86b5 --- /dev/null +++ b/schema_validation/schema_resources_test.go @@ -0,0 +1,625 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package schema_validation + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" + + "github.com/pb33f/libopenapi-validator/config" +) + +func TestJSONPointerForNode_EscapesMappingKeysAndSequences(t *testing.T) { + spec := `paths: + /objects: + get: + parameters: + - name: filter + in: query + schema: + type: object + properties: + tilde~name: + type: string + slash/name: + type: string` + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &root)) + + target := mappingValue( + mappingValue( + mappingValue( + mappingValue( + mappingValue(root.Content[0], "paths"), + "/objects", + ), + "get", + ), + "parameters", + ).Content[0], + "schema", + ) + + pointer, ok := jsonPointerForNode(&root, target) + + require.True(t, ok) + assert.Equal(t, "/paths/~1objects/get/parameters/0/schema", pointer) + + slashProperty := mappingValue(mappingValue(target, "properties"), "slash/name") + pointer, ok = jsonPointerForNode(&root, slashProperty) + + require.True(t, ok) + assert.Equal(t, "/paths/~1objects/get/parameters/0/schema/properties/slash~1name", pointer) +} + +func TestJSONPointerForNode_EdgeCases(t *testing.T) { + assert.False(t, func() bool { + _, ok := jsonPointerForNode(nil, nil) + return ok + }()) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`name: test`), &root)) + + pointer, ok := jsonPointerForNode(&root, &root) + require.True(t, ok) + assert.Empty(t, pointer) + + missing := &yaml.Node{Kind: yaml.ScalarNode, Value: "missing"} + _, ok = jsonPointerForNode(&root, missing) + assert.False(t, ok) + + pointer, ok = jsonPointerForNode(root.Content[0], &root) + require.True(t, ok) + assert.Empty(t, pointer) + + emptyDocument := &yaml.Node{Kind: yaml.DocumentNode} + _, ok = jsonPointerForNode(emptyDocument, missing) + assert.False(t, ok) +} + +func TestCompileSchemaForValidation_EdgeCases(t *testing.T) { + compiled, err := CompileSchemaForValidation(nil, SchemaValidationPurposeGeneric, config.NewValidationOptions(), 3.1) + require.NoError(t, err) + assert.Nil(t, compiled) + + compiled, err = CompileSchemaForValidation( + &base.Schema{Type: []string{"string"}}, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "low-level information") +} + +func TestCompileSchemaForValidation_SingleSchemaCompileFailure(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Thing: + type: not-a-real-type` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + compiled, err := CompileSchemaForValidation( + model.Model.Components.Schemas.GetOrZero("Thing").Schema(), + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON schema compile failed") +} + +func TestSingleSchemaCompilePreferred_LocalReferenceSchemaUsesResourceCompiler(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Inline: + type: object + properties: + id: + type: string + Referenced: + $ref: '#/components/schemas/Inline'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + inlineSchema := model.Model.Components.Schemas.GetOrZero("Inline").Schema() + inlineRendered, err := renderRootSchemaForValidation(inlineSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.True(t, singleSchemaCompilePreferred(inlineSchema, inlineRendered)) + + referencedSchema := model.Model.Components.Schemas.GetOrZero("Referenced").Schema() + referencedRendered, err := renderRootSchemaForValidation(referencedSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.False(t, singleSchemaCompilePreferred(referencedSchema, referencedRendered)) + assert.True(t, schemaHasReachableRefs(referencedSchema)) +} + +func TestSingleSchemaCompilePreferred_ExternalResourceSchemaUsesResourceCompiler(t *testing.T) { + tempDir := t.TempDir() + rootPath := filepath.Join(tempDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Referenced: + $ref: './models.yaml#/components/schemas/External'`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "models.yaml"), []byte(`components: + schemas: + External: + type: object + properties: + id: + type: string`), 0o600)) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = rootPath + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml"} + + rootSpec, err := os.ReadFile(rootPath) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + referencedSchema := model.Model.Components.Schemas.GetOrZero("Referenced").Schema() + referencedRendered, err := renderRootSchemaForValidation(referencedSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.False(t, singleSchemaCompilePreferred(referencedSchema, referencedRendered)) + require.True(t, schemaHasReachableRefs(referencedSchema)) + + resourceSet, err := buildSchemaDocumentResources(referencedSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + require.NotNil(t, resourceSet) + assert.Len(t, resourceSet.resources, 2) + assert.Len(t, resourceSet.resourceNodes, 2) + assert.NotEmpty(t, resourceSet.entryName) + + var foundExternal bool + for resourceName := range resourceSet.resourceNodes { + if strings.HasSuffix(resourceName, "models.yaml") { + foundExternal = true + break + } + } + assert.True(t, foundExternal) +} + +func TestSingleSchemaCompilePreferred_UnrelatedExternalResourceStaysSingleSchema(t *testing.T) { + tempDir := t.TempDir() + rootPath := filepath.Join(tempDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /external: + post: + requestBody: + content: + application/json: + schema: + $ref: './models.yaml#/components/schemas/External' + responses: + '200': + description: ok +components: + schemas: + Inline: + type: object + properties: + id: + type: string`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "models.yaml"), []byte(`components: + schemas: + External: + type: object + properties: + name: + type: string`), 0o600)) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = rootPath + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml"} + + rootSpec, err := os.ReadFile(rootPath) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + inlineSchema := model.Model.Components.Schemas.GetOrZero("Inline").Schema() + inlineRendered, err := renderRootSchemaForValidation(inlineSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + + assert.True(t, singleSchemaCompilePreferred(inlineSchema, inlineRendered)) + assert.False(t, schemaHasReachableRefs(inlineSchema)) + resourceSet, err := buildSchemaDocumentResources(inlineSchema, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.Nil(t, resourceSet) +} + +func TestBuildSchemaDocumentResources_NoLowSchema(t *testing.T) { + resourceSet, err := buildSchemaDocumentResources( + &base.Schema{}, + SchemaValidationPurposeGeneric, + ) + + assert.Nil(t, resourceSet) + require.NoError(t, err) + assert.False(t, schemaHasReachableRefs(nil)) +} + +func TestCollectSchemaRefValues_IgnoresSchemaNameMaps(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object +properties: + $ref: + type: string + child: + $ref: '#/components/schemas/Child' +$defs: + $ref: + type: object`), &root)) + + refs := collectSchemaRefValues(root.Content[0]) + + require.Len(t, refs, 1) + assert.Equal(t, "#/components/schemas/Child", refs[0]) +} + +func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { + rendered, err := renderRootSchemaForValidation(nil, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.Nil(t, rendered) + + assert.Nil(t, sourceNodesForRenderedSchema("schema", nil)) + renderedNode := &yaml.Node{Kind: yaml.MappingNode} + sourceNodes := sourceNodesForRenderedSchema("schema", renderedNode) + assert.Same(t, renderedNode, sourceNodes[""]) + assert.Same(t, renderedNode, sourceNodes["schema"]) + + rendered, err = renderYAMLNodeForValidation(nil, SchemaValidationPurposeGeneric) + require.NoError(t, err) + assert.Nil(t, rendered) + + rendered, err = renderYAMLNodeForValidation( + &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "not-a-document-map"}, + }, + }, + SchemaValidationPurposeGeneric, + ) + assert.Nil(t, rendered) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON conversion") + + resources := make(map[string][]byte) + resourceNodes := make(map[string]*yaml.Node) + rendered, err = addSchemaDocumentResource( + resources, + resourceNodes, + "", + &yaml.Node{Kind: yaml.MappingNode}, + SchemaValidationPurposeGeneric, + ) + require.NoError(t, err) + assert.Nil(t, rendered) + assert.Empty(t, resources) + assert.Empty(t, resourceNodes) + + rendered, err = addSchemaDocumentResource( + resources, + resourceNodes, + "https://example.com/root.json", + nil, + SchemaValidationPurposeGeneric, + ) + require.NoError(t, err) + assert.Nil(t, rendered) + assert.Empty(t, resources) + assert.Empty(t, resourceNodes) + + var resourceRoot yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object`), &resourceRoot)) + + rendered, err = addSchemaDocumentResource( + resources, + resourceNodes, + "https://example.com/root.json", + &resourceRoot, + SchemaValidationPurposeGeneric, + ) + require.NoError(t, err) + require.NotNil(t, rendered) + assert.NotEmpty(t, resources["https://example.com/root.json"]) + assert.Same(t, rendered.RenderedNode, resourceNodes["https://example.com/root.json"]) + + rendered, err = addSchemaDocumentResource( + resources, + resourceNodes, + "https://example.com/invalid.json", + &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "not-a-document-map"}, + }, + }, + SchemaValidationPurposeGeneric, + ) + assert.Nil(t, rendered) + require.Error(t, err) + assert.Contains(t, err.Error(), "schema resource") + + pruneDirectionalRequiredEverywhere(nil, SchemaValidationPurposeRequestBody) + assert.Nil(t, cloneYAMLNode(nil)) + + tokens, ok := jsonPointerTokensForNode(nil, &yaml.Node{}) + assert.Nil(t, tokens) + assert.False(t, ok) + + target := &yaml.Node{Kind: yaml.ScalarNode, Value: "value"} + mappingWithNilKey := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{nil, target}, + } + tokens, ok = jsonPointerTokensForNode(mappingWithNilKey, target) + require.True(t, ok) + assert.Equal(t, []string{""}, tokens) +} + +func TestCompileSchemaForValidation_ResourceCompileFailure(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Thing: + $ref: '#/components/schemas/Bad' + Bad: + type: not-a-real-type` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Thing").Schema() + + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON schema compile failed") +} + +func TestCompileSchemaForValidation_ExternalResourceCompileFailure(t *testing.T) { + tempDir := t.TempDir() + rootPath := filepath.Join(tempDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Thing: + $ref: './models.yaml#/components/schemas/Bad'`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "models.yaml"), []byte(`components: + schemas: + Bad: + type: not-a-real-type`), 0o600)) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = rootPath + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml"} + + rootSpec, err := os.ReadFile(rootPath) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + compiled, err := CompileSchemaForValidation( + model.Model.Components.Schemas.GetOrZero("Thing").Schema(), + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON schema compile failed") +} + +func TestCloneYAMLNodeAndPruneDirectionalRequiredEverywhere(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object +required: [id, name] +properties: + id: + type: string + readOnly: true + name: + type: string`), &root)) + + cloned := cloneYAMLNode(&root) + pruneDirectionalRequiredEverywhere(cloned, SchemaValidationPurposeRequestBody) + + originalRequired := mappingValue(root.Content[0], "required") + require.Len(t, originalRequired.Content, 2) + + clonedRequired := mappingValue(cloned.Content[0], "required") + require.Len(t, clonedRequired.Content, 1) + assert.Equal(t, "name", clonedRequired.Content[0].Value) +} + +func TestCompileSchemaForValidation_CircularReference(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Error: + type: object + required: [code] + properties: + code: + type: string + details: + type: array + items: + $ref: '#/components/schemas/Error'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Components.Schemas.GetOrZero("Error").Schema() + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + require.NoError(t, err) + require.NotNil(t, compiled) + require.NotNil(t, compiled.CompiledSchema) + require.NotEmpty(t, compiled.ResourceNodes) + assert.NoError(t, compiled.CompiledSchema.Validate(map[string]any{ + "code": "root", + "details": []any{ + map[string]any{"code": "child"}, + }, + })) + assert.Error(t, compiled.CompiledSchema.Validate(map[string]any{ + "code": "root", + "details": []any{ + map[string]any{"code": 42}, + }, + })) +} + +func TestCompileSchemaForValidation_DirectionalRequiredAcrossReferences(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: + /products: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Product' +components: + schemas: + Product: + type: object + required: [id, name, secret] + properties: + id: + type: string + readOnly: true + name: + type: string + secret: + type: string + writeOnly: true` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + operation := model.Model.Paths.PathItems.GetOrZero("/products").Post + requestSchema := operation.RequestBody.Content.GetOrZero("application/json").Schema.Schema() + responseSchema := operation.Responses.Codes.GetOrZero("200").Content.GetOrZero("application/json").Schema.Schema() + + requestCompiled, err := CompileSchemaForValidation( + requestSchema, + SchemaValidationPurposeRequestBody, + config.NewValidationOptions(), + 3.1, + ) + require.NoError(t, err) + assert.NoError(t, requestCompiled.CompiledSchema.Validate(map[string]any{ + "name": "Desk", + "secret": "internal", + })) + assert.Error(t, requestCompiled.CompiledSchema.Validate(map[string]any{ + "name": "Desk", + })) + + responseCompiled, err := CompileSchemaForValidation( + responseSchema, + SchemaValidationPurposeResponseBody, + config.NewValidationOptions(), + 3.1, + ) + require.NoError(t, err) + assert.NoError(t, responseCompiled.CompiledSchema.Validate(map[string]any{ + "id": "p1", + "name": "Desk", + })) + assert.Error(t, responseCompiled.CompiledSchema.Validate(map[string]any{ + "name": "Desk", + })) +} diff --git a/schema_validation/validate_schema.go b/schema_validation/validate_schema.go index 7f1f0553..abb8d475 100644 --- a/schema_validation/validate_schema.go +++ b/schema_validation/validate_schema.go @@ -1,5 +1,6 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT + package schema_validation import ( @@ -12,9 +13,7 @@ import ( "reflect" "regexp" "strconv" - "sync" - "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/santhosh-tekuri/jsonschema/v6" "go.yaml.in/yaml/v4" @@ -72,14 +71,13 @@ var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`) type schemaValidator struct { options *config.ValidationOptions logger *slog.Logger - lock sync.Mutex } // NewSchemaValidatorWithLogger will create a new SchemaValidator instance, ready to accept schemas and payloads to validate. func NewSchemaValidatorWithLogger(logger *slog.Logger, opts ...config.Option) SchemaValidator { options := config.NewValidationOptions(opts...) - return &schemaValidator{options: options, logger: logger, lock: sync.Mutex{}} + return &schemaValidator{options: options, logger: logger} } // NewSchemaValidator will create a new SchemaValidator instance, ready to accept schemas and payloads to validate. @@ -124,6 +122,7 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload var renderedSchema []byte var renderedNode *yaml.Node + var resourceNodes map[string]*yaml.Node var compiledSchema *jsonschema.Schema // Check cache first — reuses existing SchemaCache (populated by NewValidationOptions). @@ -136,58 +135,21 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload cached != nil && cached.CompiledSchema != nil { renderedSchema = cached.RenderedInline renderedNode = cached.RenderedNode + resourceNodes = cached.ResourceNodes compiledSchema = cached.CompiledSchema } } // 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() - - if renderErr != nil { - 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, - HowToFix: liberrors.HowToFixInvalidSchema, - Context: string(renderedSchema), - }) - return false, validationErrors - } - - // MarshalYAMLInlineWithContext returns *yaml.Node (from NodeBuilder.Render) - renderedNode, _ = nodeIface.(*yaml.Node) - - // 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) + compiled, compileErr := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + s.options, + version, + ) if compileErr != nil { - line := 1 - col := 0 - if schema.GoLow().Type.KeyNode != nil { - line = schema.GoLow().Type.KeyNode.Line - col = schema.GoLow().Type.KeyNode.Column - } + line, col := schemaLineColumn(schema) validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.Schema, ValidationSubType: helpers.Schema, @@ -200,17 +162,14 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload }) return false, validationErrors } + renderedSchema = compiled.RenderedInline + renderedNode = compiled.RenderedNode + resourceNodes = compiled.ResourceNodes + compiledSchema = compiled.CompiledSchema // 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, - }) + s.options.SchemaCache.Store(cacheKey, compiled.ToCacheEntry(schema)) } } @@ -218,12 +177,7 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload err := json.Unmarshal(payload, &decodedObject) if err != nil { // cannot decode the request body, so it's not valid - line := 1 - col := 0 - if schema.GoLow().Type.KeyNode != nil { - line = schema.GoLow().Type.KeyNode.Line - col = schema.GoLow().Type.KeyNode.Column - } + line, col := schemaLineColumn(schema) validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.RequestBodyValidation, ValidationSubType: helpers.Schema, @@ -248,16 +202,11 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload if errors.As(scErrs, &jk) { // flatten the validationErrors - schFlatErr := jk.BasicOutput().Errors + schFlatErr := helpers.FlattenSchemaOutputErrors(jk.DetailedOutput()) schemaValidationErrors = extractBasicErrors(schFlatErr, renderedSchema, - renderedNode, decodedObject, payload, jk, schemaValidationErrors) - } - line := 1 - col := 0 - if schema.GoLow().Type.KeyNode != nil { - line = schema.GoLow().Type.KeyNode.Line - col = schema.GoLow().Type.KeyNode.Column + renderedNode, resourceNodes, decodedObject, payload, jk, schemaValidationErrors) } + line, col := schemaLineColumn(schema) validationErrors = append(validationErrors, &liberrors.ValidationError{ ValidationType: helpers.Schema, @@ -277,8 +226,16 @@ func (s *schemaValidator) validateSchemaWithVersion(schema *base.Schema, payload return true, nil } +func schemaLineColumn(schema *base.Schema) (int, int) { + if schema == nil || schema.GoLow() == nil || schema.GoLow().Type.KeyNode == nil { + return 1, 0 + } + return schema.GoLow().Type.KeyNode.Line, schema.GoLow().Type.KeyNode.Column +} + func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, renderedSchema []byte, renderedNode *yaml.Node, + resourceNodes map[string]*yaml.Node, decodedObject interface{}, payload []byte, jk *jsonschema.ValidationError, schemaValidationErrors []*liberrors.SchemaValidationFailure, @@ -291,6 +248,9 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, var rootNode *yaml.Node if renderedNode != nil { rootNode = renderedNode + if rootNode.Kind == yaml.DocumentNode && len(rootNode.Content) > 0 { + rootNode = rootNode.Content[0] + } } else if len(renderedSchema) > 0 { // Fallback: parse bytes ONCE var docNode yaml.Node @@ -312,7 +272,12 @@ func extractBasicErrors(schFlatErrs []jsonschema.OutputUnit, // locate the violated property in the schema var located *yaml.Node if rootNode != nil { - located = LocateSchemaPropertyNodeByJSONPath(rootNode, er.KeywordLocation) + located = LocateSchemaPropertyNodeByJSONPathWithResources( + rootNode, + resourceNodes, + er.KeywordLocation, + er.AbsoluteKeywordLocation, + ) } // extract the element specified by the instance diff --git a/schema_validation/validate_schema_extract_errors_test.go b/schema_validation/validate_schema_extract_errors_test.go index 1f263fed..29c1c263 100644 --- a/schema_validation/validate_schema_extract_errors_test.go +++ b/schema_validation/validate_schema_extract_errors_test.go @@ -1,4 +1,4 @@ -// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package schema_validation @@ -7,6 +7,7 @@ import ( "testing" "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" "github.com/santhosh-tekuri/jsonschema/v6" "go.yaml.in/yaml/v4" "golang.org/x/text/message" @@ -60,7 +61,16 @@ properties: }, } - failures := extractBasicErrors(flatErrors, renderedSchema, nil, map[string]any{"item": map[string]any{}}, payload, nil, nil) + failures := extractBasicErrors( + flatErrors, + renderedSchema, + nil, + nil, + map[string]any{"item": map[string]any{}}, + payload, + nil, + nil, + ) assert.Len(t, failures, 2) var docNode yaml.Node @@ -78,3 +88,51 @@ properties: assert.Equal(t, adjustedLine(sequenceNode), failures[1].Line) assert.Equal(t, sequenceNode.Column, failures[1].Column) } + +func TestExtractBasicErrors_UsesExternalResourceNodeForAbsoluteKeywordLocation(t *testing.T) { + renderedSchema := []byte(`components: + schemas: + Entry: + $ref: './models.yaml#/components/schemas/External'`) + payload := []byte(`{"id":42}`) + + var entryRoot yaml.Node + assert.NoError(t, yaml.Unmarshal(renderedSchema, &entryRoot)) + + var externalRoot yaml.Node + assert.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + External: + type: object + properties: + id: + type: string`), &externalRoot)) + + flatErrors := []jsonschema.OutputUnit{ + { + KeywordLocation: "/components/schemas/Entry/$ref/properties/id/type", + AbsoluteKeywordLocation: "file:///tmp/models.yaml#/components/schemas/External/properties/id/type", + InstanceLocation: "/id", + Error: &jsonschema.OutputError{ + Kind: stubErrorKind{msg: "got number, want string"}, + }, + }, + } + + failures := extractBasicErrors( + flatErrors, + renderedSchema, + &entryRoot, + map[string]*yaml.Node{"file:///tmp/models.yaml": &externalRoot}, + map[string]any{"id": float64(42)}, + payload, + nil, + nil, + ) + + require.Len(t, failures, 1) + located := LocateSchemaPropertyNodeByJSONPath(externalRoot.Content[0], "/components/schemas/External/properties/id/type") + require.NotNil(t, located) + assert.Equal(t, located.Line, failures[0].Line) + assert.Equal(t, located.Column, failures[0].Column) +} diff --git a/schema_validation/validate_schema_openapi_test.go b/schema_validation/validate_schema_openapi_test.go index 482e7e56..603e857d 100644 --- a/schema_validation/validate_schema_openapi_test.go +++ b/schema_validation/validate_schema_openapi_test.go @@ -330,7 +330,7 @@ components: assert.Error(t, err, "RenderInline should not error on circular refs") }) - t.Run("should fail validating", func(t *testing.T) { + t.Run("should validate circular references", func(t *testing.T) { sv := NewSchemaValidator() schemaB := model.Model.Components.Schemas.GetOrZero("b").Schema() @@ -341,20 +341,15 @@ components: exampleJSON := `{"z": "", "b": {"z": ""}}` valid, errors := sv.ValidateSchemaString(schemaB, exampleJSON) - assert.False(t, valid, "Schema with circular refs should currently fail validation") - assert.NotNil(t, errors, "Should have validation errors") + assert.True(t, valid, "Schema with circular refs should validate valid payloads") + assert.Empty(t, errors, "Should have no validation errors") - foundCompilationError := false - for _, err := range errors { - if err.Message == "schema does not pass validation" && - err.Reason != "" && - (err.Reason == "The schema cannot be decoded: schema render failure, circular reference: `#/components/schemas/b`" || - err.Reason == "The schema cannot be decoded: schema render failure, circular reference: `#/components/schemas/Node`") { - foundCompilationError = true - } - assert.Nil(t, err.SchemaValidationErrors, "Rendering errors should not have SchemaValidationErrors") - } - assert.True(t, foundCompilationError, "Should have schema compilation error for circular references") + valid, errors = sv.ValidateSchemaString(schemaB, `{"z": 42, "b": {"z": ""}}`) + + assert.False(t, valid, "Schema with circular refs should still reject invalid nested payloads") + assert.NotNil(t, errors, "Should have validation errors") + assert.NotEmpty(t, errors[0].SchemaValidationErrors) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "got number") }) } diff --git a/validator.go b/validator.go index 820fafd4..d9415a74 100644 --- a/validator.go +++ b/validator.go @@ -4,7 +4,6 @@ package validator import ( - "fmt" "net/http" "sort" "sync" @@ -496,28 +495,17 @@ func warmMediaTypeSchema( hash := schema_validation.SchemaCacheKey(schema.GoLow().Hash(), version, purpose) if _, exists := schemaCache.Load(hash); !exists { - rendered, err := schema_validation.RenderSchemaForValidation(schema, purpose) - if err != nil || rendered == nil || len(rendered.RenderedInline) == 0 { - return - } - compiledSchema, compileErr := helpers.NewCompiledSchemaWithVersion( - fmt.Sprintf("%x", hash), - rendered.RenderedJSON, + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + purpose, options, version, ) - if compileErr != nil || compiledSchema == nil { + if err != nil || compiled == nil || compiled.CompiledSchema == nil { return } - schemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: schema, - RenderedInline: rendered.RenderedInline, - ReferenceSchema: rendered.ReferenceSchema, - RenderedJSON: rendered.RenderedJSON, - CompiledSchema: compiledSchema, - RenderedNode: rendered.RenderedNode, - }) + schemaCache.Store(hash, compiled.ToCacheEntry(schema)) } } } @@ -545,30 +533,18 @@ func warmParameterSchema(param *v3.Parameter, schemaCache cache.SchemaCache, opt hash := schema_validation.SchemaCacheKey(schema.GoLow().Hash(), version, schema_validation.SchemaValidationPurposeGeneric) if _, exists := schemaCache.Load(hash); !exists { - rendered, err := schema_validation.RenderSchemaForValidation(schema, - schema_validation.SchemaValidationPurposeGeneric) - if err != nil || rendered == nil || len(rendered.RenderedInline) == 0 { - return - } - compiledSchema, compileErr := helpers.NewCompiledSchemaWithVersion( - fmt.Sprintf("%x", hash), - rendered.RenderedJSON, + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + schema_validation.SchemaValidationPurposeGeneric, options, version, ) - if compileErr != nil || compiledSchema == nil { + if err != nil || compiled == nil || compiled.CompiledSchema == nil { return } // Store in cache using the shared SchemaCache type - schemaCache.Store(hash, &cache.SchemaCacheEntry{ - Schema: schema, - RenderedInline: rendered.RenderedInline, - ReferenceSchema: rendered.ReferenceSchema, - RenderedJSON: rendered.RenderedJSON, - CompiledSchema: compiledSchema, - RenderedNode: rendered.RenderedNode, - }) + schemaCache.Store(hash, compiled.ToCacheEntry(schema)) } } } diff --git a/validator_examples_test.go b/validator_examples_test.go index a0fb8e2b..8dee1ade 100644 --- a/validator_examples_test.go +++ b/validator_examples_test.go @@ -1,4 +1,4 @@ -// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package validator @@ -190,7 +190,7 @@ func ExampleNewValidator_validateHttpRequestResponse() { } } // Output: Type: response, Failure: 200 response body for '/pet/findByStatus' failed to validate schema - // Schema Error: got string, want integer, Line: 19, Col: 27 + // Schema Error: got string, want integer, Line: 1031, Col: 21 } func ExampleNewValidator_validateHttpResponse() { @@ -259,7 +259,7 @@ func ExampleNewValidator_validateHttpResponse() { } } // Output: Type: response, Failure: 200 response body for '/pet/findByStatus' failed to validate schema - // Schema Error: got string, want integer, Line: 19, Col: 27 + // Schema Error: got string, want integer, Line: 1031, Col: 21 } func ExampleNewValidator_testResponseHeaders() { diff --git a/validator_test.go b/validator_test.go index 3567c378..007b3ff6 100644 --- a/validator_test.go +++ b/validator_test.go @@ -2398,7 +2398,7 @@ func TestCacheWarming_MediaTypeSchemaWithNilGoLow(t *testing.T) { assert.Equal(t, 0, schemaCacheEntryCount(options.SchemaCache)) } -func TestCacheWarming_ParameterRenderFailure(t *testing.T) { +func TestCacheWarming_ParameterCircularReference(t *testing.T) { param := cacheWarmingTestParameter(t, `schema: $ref: '#/components/schemas/Error'`, ` components: @@ -2416,7 +2416,7 @@ components: warmParameterSchema(param, options.SchemaCache, options, 3.1) - assert.Equal(t, 0, schemaCacheEntryCount(options.SchemaCache)) + assert.Equal(t, 1, schemaCacheEntryCount(options.SchemaCache)) } func TestCacheWarming_ParameterCompileFailure(t *testing.T) { From a18a1894c27d6ad679f71dcbde171824843c3675 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Sun, 21 Jun 2026 14:02:29 -0400 Subject: [PATCH 2/3] coverage --- parameters/validate_parameter.go | 2 +- parameters/validate_parameter_test.go | 72 ++++- schema_validation/locate_schema_property.go | 5 +- .../locate_schema_property_test.go | 16 + schema_validation/schema_resources_test.go | 295 ++++++++++++++++++ 5 files changed, 383 insertions(+), 7 deletions(-) diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index fe2862c8..13c3e988 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index 7cd842fd..308a55f9 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -1,6 +1,10 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package parameters import ( + stdErrors "errors" "net/http" "os" "path/filepath" @@ -14,6 +18,7 @@ import ( lowv3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" @@ -29,6 +34,22 @@ func Test_ForceCompilerError(t *testing.T) { require.Empty(t, result) } +func TestValidateParameterSchema_NilSchemaReturnsNoErrors(t *testing.T) { + result := ValidateParameterSchema( + nil, + "anything", + "", + "query", + "query parameter", + "filter", + helpers.ParameterValidation, + helpers.Query, + config.NewValidationOptions(), + ) + + require.Empty(t, result) +} + func TestValidateParameterSchema_CircularReferenceWithCacheDisabled(t *testing.T) { spec := []byte(`openapi: 3.1.0 info: @@ -100,7 +121,8 @@ components: ) require.Len(t, validationErrors, 1) - requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + failure := requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + assert.NotNil(t, failure) } func TestValidateParameterSchema_ExternalReferenceWithCacheDisabled(t *testing.T) { @@ -158,7 +180,8 @@ paths: ) require.Len(t, validationErrors, 1) - requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + failure := requireParameterFailureContaining(t, validationErrors[0].SchemaValidationErrors, "got number") + assert.NotNil(t, failure) } func requireParameterFailureContaining( @@ -176,6 +199,51 @@ func requireParameterFailureContaining( return nil } +func TestFormatJsonSchemaValidationError_RendersSchemaWhenReferenceSchemaMissing(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Name: + type: string +`) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Name").Schema() + + jsch, err := helpers.NewCompiledSchema( + "name", + []byte(`{"type":"string"}`), + config.NewValidationOptions(), + ) + require.NoError(t, err) + + var validationErr *jsonschema.ValidationError + require.True(t, stdErrors.As(jsch.Validate(42), &validationErr)) + + validationErrors := formatJsonSchemaValidationError( + schema, + validationErr, + "query", + "query parameter", + "name", + helpers.ParameterValidation, + helpers.Query, + "", + "", + "", + ) + + require.Len(t, validationErrors, 1) + require.Len(t, validationErrors[0].SchemaValidationErrors, 1) + assert.Contains(t, validationErrors[0].SchemaValidationErrors[0].ReferenceSchema, "type: string") +} + func TestHeaderSchemaNoType(t *testing.T) { bytes := []byte(`{ "openapi": "3.0.0", diff --git a/schema_validation/locate_schema_property.go b/schema_validation/locate_schema_property.go index 12d00207..3a445705 100644 --- a/schema_validation/locate_schema_property.go +++ b/schema_validation/locate_schema_property.go @@ -75,10 +75,7 @@ func lookupResourceNode(resourceNodes map[string]*yaml.Node, resourceName string if resourceNode := resourceNodes[parsedURL.String()]; resourceNode != nil { return resourceNode } - filePath, err := url.PathUnescape(parsedURL.Path) - if err != nil { - filePath = parsedURL.Path - } + filePath, _ := url.PathUnescape(parsedURL.Path) if resourceNode := resourceNodes[filePath]; resourceNode != nil { return resourceNode } diff --git a/schema_validation/locate_schema_property_test.go b/schema_validation/locate_schema_property_test.go index f10efd9a..feb30890 100644 --- a/schema_validation/locate_schema_property_test.go +++ b/schema_validation/locate_schema_property_test.go @@ -113,4 +113,20 @@ func TestLookupResourceNode_EdgeCases(t *testing.T) { ) assert.Same(t, escapedPathNode, located) + + canonicalPathNode := &yaml.Node{Kind: yaml.MappingNode} + located = lookupResourceNode( + map[string]*yaml.Node{"file:///tmp/models.yaml": canonicalPathNode}, + "file://localhost/tmp/models.yaml", + ) + + assert.Same(t, canonicalPathNode, located) + + parsedStringNode := &yaml.Node{Kind: yaml.MappingNode} + located = lookupResourceNode( + map[string]*yaml.Node{"file:///tmp/models%20with%20space.yaml": parsedStringNode}, + "file:///tmp/models with space.yaml", + ) + + assert.Same(t, parsedStringNode, located) } diff --git a/schema_validation/schema_resources_test.go b/schema_validation/schema_resources_test.go index 4aaa86b5..014207b9 100644 --- a/schema_validation/schema_resources_test.go +++ b/schema_validation/schema_resources_test.go @@ -12,6 +12,8 @@ import ( "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/high/base" + lowbase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/libopenapi/index" "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" "go.yaml.in/yaml/v4" @@ -132,6 +134,57 @@ components: assert.Contains(t, err.Error(), "JSON schema compile failed") } +func TestCompileSchemaForValidation_BuildResourceFailuresAndFallbacks(t *testing.T) { + t.Run("schema root not found in indexed document", func(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Name: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + + schema := model.Model.Components.Schemas.GetOrZero("Name").Schema() + var detached yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`$ref: '#/components/schemas/Name'`), &detached)) + schema.GoLow().RootNode = detached.Content[0] + + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "schema node was not found") + }) + + t.Run("low schema with no index falls back to single schema compiler", func(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`$ref: '#/components/schemas/Name'`), &root)) + schema := base.NewSchema(&lowbase.Schema{RootNode: root.Content[0]}) + + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + require.NoError(t, err) + require.NotNil(t, compiled) + assert.NotNil(t, compiled.CompiledSchema) + }) +} + func TestSingleSchemaCompilePreferred_LocalReferenceSchemaUsesResourceCompiler(t *testing.T) { spec := `openapi: 3.1.0 info: @@ -306,6 +359,9 @@ $defs: } func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { + var compiled *CompiledValidationSchema + assert.Nil(t, compiled.ToCacheEntry(nil)) + rendered, err := renderRootSchemaForValidation(nil, SchemaValidationPurposeGeneric) require.NoError(t, err) assert.Nil(t, rendered) @@ -333,6 +389,16 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "JSON conversion") + rendered, err = renderYAMLNodeForValidation( + &yaml.Node{ + Kind: yaml.AliasNode, + }, + SchemaValidationPurposeGeneric, + ) + assert.Nil(t, rendered) + require.Error(t, err) + assert.Contains(t, err.Error(), "schema render encode failed") + resources := make(map[string][]byte) resourceNodes := make(map[string]*yaml.Node) rendered, err = addSchemaDocumentResource( @@ -405,6 +471,135 @@ func TestSchemaResourceHelpers_EdgeCases(t *testing.T) { tokens, ok = jsonPointerTokensForNode(mappingWithNilKey, target) require.True(t, ok) assert.Equal(t, []string{""}, tokens) + + assert.Empty(t, ensureSchemaResourceName(nil, nil, 1)) +} + +func TestRenderYAMLNodeForValidation_DirectionalPurposeClonesAndPrunes(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object +required: [id, name] +properties: + id: + type: string + readOnly: true + name: + type: string`), &root)) + + rendered, err := renderYAMLNodeForValidation(root.Content[0], SchemaValidationPurposeRequestBody) + + require.NoError(t, err) + require.NotNil(t, rendered) + assert.NotSame(t, root.Content[0], rendered.RenderedNode) + + originalRequired := mappingValue(root.Content[0], "required") + require.Len(t, originalRequired.Content, 2) + + renderedRequired := mappingValue(rendered.RenderedNode, "required") + require.Len(t, renderedRequired.Content, 1) + assert.Equal(t, "name", renderedRequired.Content[0].Value) +} + +func TestCanonicalResourceName_EdgeCases(t *testing.T) { + assert.Empty(t, canonicalResourceName("")) + assert.Equal(t, "https://example.com/schema.yaml", canonicalResourceName("https://example.com/schema.yaml")) + + resourceName := canonicalResourceName(filepath.Join("fixtures", "models.yaml")) + + assert.True(t, strings.HasPrefix(resourceName, "file:///")) + assert.True(t, strings.HasSuffix(resourceName, "/fixtures/models.yaml")) +} + +func TestCollectSchemaRefValues_DocumentSequenceAndNilEdges(t *testing.T) { + assert.Empty(t, collectSchemaRefValues(nil)) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`allOf: + - $ref: '#/components/schemas/First' + - properties: + $ref: + type: string + items: + $ref: '#/components/schemas/Second'`), &root)) + + refs := collectSchemaRefValues(&root) + + assert.Equal(t, []string{ + "#/components/schemas/First", + "#/components/schemas/Second", + }, refs) +} + +func TestAddReachableSchemaResources_GuardsAndSkips(t *testing.T) { + assert.NoError(t, addReachableSchemaResources(nil, nil, nil, nil, SchemaValidationPurposeGeneric)) + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`components: + schemas: + Root: + type: object + properties: + missing: + $ref: '#/components/schemas/Missing' + child: + $ref: '#/components/schemas/Child' + other: + $ref: '#/components/schemas/Child' + Child: + type: object + properties: + next: + $ref: '#/components/schemas/Child'`), &root)) + + specIndex := index.NewSpecIndex(&root) + rootSchemaNode := mappingValue(mappingValue(mappingValue(root.Content[0], "components"), "schemas"), "Root") + require.NotNil(t, rootSchemaNode) + childSchemaNode := mappingValue(mappingValue(mappingValue(root.Content[0], "components"), "schemas"), "Child") + require.NotNil(t, childSchemaNode) + + resourceSet := &schemaDocumentResourceSet{ + resources: make(map[string][]byte), + resourceNodes: make(map[string]*yaml.Node), + } + state := &schemaResourceBuildState{ + resourceNames: make(map[*index.SpecIndex]string), + seenRefs: make(map[string]struct{}), + seenNodes: map[*yaml.Node]struct{}{ + childSchemaNode: {}, + }, + } + + require.NoError(t, addReachableSchemaResources( + resourceSet, + state, + specIndex, + rootSchemaNode, + SchemaValidationPurposeGeneric, + )) + assert.NotEmpty(t, state.seenRefs) + + require.NoError(t, addReachableSchemaResources( + resourceSet, + state, + specIndex, + childSchemaNode, + SchemaValidationPurposeGeneric, + )) +} + +func TestAppendJSONPointerTokensForNode_DocumentNodeDirectCall(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`items: + - name: first`), &root)) + + target := mappingValue(root.Content[0].Content[1].Content[0], "name") + require.NotNil(t, target) + + var tokens []string + ok := appendJSONPointerTokensForNode(&root, target, &tokens) + + require.True(t, ok) + assert.Equal(t, []string{"items", "0", "name"}, tokens) } func TestCompileSchemaForValidation_ResourceCompileFailure(t *testing.T) { @@ -478,6 +673,106 @@ components: assert.Contains(t, err.Error(), "JSON schema compile failed") } +func TestCompileSchemaForValidation_RootResourceRenderFailure(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Root: + $ref: '#/components/schemas/Child' + Child: + type: string` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Root").Schema() + + appendInvalidAliasValue(schema.GoLow().GetIndex().GetRootNode()) + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "schema resource") +} + +func TestCompileSchemaForValidation_NestedResourceRenderFailure(t *testing.T) { + tempDir := t.TempDir() + rootPath := filepath.Join(tempDir, "openapi.yaml") + require.NoError(t, os.WriteFile(rootPath, []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Root: + $ref: './models.yaml#/components/schemas/Child'`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "models.yaml"), []byte(`components: + schemas: + Child: + $ref: './grand.yaml#/components/schemas/Grand'`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "grand.yaml"), []byte(`components: + schemas: + Grand: + type: string`), 0o600)) + + docConfig := datamodel.NewDocumentConfiguration() + docConfig.AllowFileReferences = true + docConfig.BasePath = tempDir + docConfig.SpecFilePath = rootPath + docConfig.FileFilter = []string{"openapi.yaml", "models.yaml", "grand.yaml"} + + rootSpec, err := os.ReadFile(rootPath) + require.NoError(t, err) + doc, err := libopenapi.NewDocumentWithConfiguration(rootSpec, docConfig) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Root").Schema() + + childRef, childIndex := schema.GoLow().GetIndex().SearchIndexForReference("./models.yaml#/components/schemas/Child") + require.NotNil(t, childRef) + require.NotNil(t, childIndex) + grandRef, grandIndex := childIndex.SearchIndexForReference("./grand.yaml#/components/schemas/Grand") + require.NotNil(t, grandRef) + require.NotNil(t, grandIndex) + + appendInvalidAliasValue(grandIndex.GetRootNode()) + compiled, err := CompileSchemaForValidation( + schema, + SchemaValidationPurposeGeneric, + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, compiled) + require.Error(t, err) + assert.Contains(t, err.Error(), "schema resource") +} + +func appendInvalidAliasValue(root *yaml.Node) { + if root == nil || root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + return + } + rootContent := root.Content[0] + if rootContent.Kind != yaml.MappingNode { + return + } + rootContent.Content = append( + rootContent.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "x-invalid-alias"}, + &yaml.Node{Kind: yaml.AliasNode}, + ) +} + func TestCloneYAMLNodeAndPruneDirectionalRequiredEverywhere(t *testing.T) { var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`type: object From d54be917562f570b472adbc067dbb16905ecff54 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Sun, 21 Jun 2026 14:23:04 -0400 Subject: [PATCH 3/3] coverage --- helpers/schema_compiler.go | 14 +-- helpers/schema_compiler_test.go | 16 +++ parameters/validate_parameter_test.go | 49 ++++++++++ requests/validate_request_test.go | 78 +++++++++++++++ responses/validate_response_test.go | 97 +++++++++++++++++++ schema_validation/schema_resources.go | 66 +++++++------ schema_validation/schema_resources_test.go | 50 ++++++++++ .../validate_schema_extract_errors_test.go | 22 +++++ 8 files changed, 353 insertions(+), 39 deletions(-) diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go index 2b8d183e..4c52889b 100644 --- a/helpers/schema_compiler.go +++ b/helpers/schema_compiler.go @@ -164,11 +164,8 @@ func transformOpenAPI30Schema(jsonSchema []byte) []byte { transformed := transformOAS30Keywords(schema) - result, err := json.Marshal(transformed) - if err != nil { - return jsonSchema - } - + // json.Unmarshal only produces JSON-marshalable values here. + result, _ := json.Marshal(transformed) return result } @@ -326,11 +323,8 @@ func transformSchemaForCoercion(jsonSchema []byte) []byte { transformed := transformCoercionInSchema(schema) - result, err := json.Marshal(transformed) - if err != nil { - return jsonSchema - } - + // json.Unmarshal only produces JSON-marshalable values here. + result, _ := json.Marshal(transformed) return result } diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go index e231a0b9..021f1cbd 100644 --- a/helpers/schema_compiler_test.go +++ b/helpers/schema_compiler_test.go @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package helpers import ( @@ -102,6 +105,19 @@ func TestNewCompiledSchemaResourcesWithVersion_InvalidResourceName(t *testing.T) assert.Contains(t, err.Error(), "failed to add resource") } +func TestNewCompiledSchemaWithVersion_InvalidResourceName(t *testing.T) { + jsch, err := NewCompiledSchemaWithVersion( + "%zz", + []byte(`{}`), + config.NewValidationOptions(), + 3.1, + ) + + assert.Nil(t, jsch) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to add resource") +} + func TestNewCompiledSchemaResourcesWithVersion_CompileFailure(t *testing.T) { jsch, err := NewCompiledSchemaResourcesWithVersion( "https://example.com/root.json#/missing", diff --git a/parameters/validate_parameter_test.go b/parameters/validate_parameter_test.go index 308a55f9..b67c31d6 100644 --- a/parameters/validate_parameter_test.go +++ b/parameters/validate_parameter_test.go @@ -19,6 +19,7 @@ import ( "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" "github.com/santhosh-tekuri/jsonschema/v6" + "golang.org/x/text/message" "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" @@ -26,6 +27,18 @@ import ( "github.com/pb33f/libopenapi-validator/helpers" ) +type parameterStubErrorKind struct { + msg string +} + +func (p parameterStubErrorKind) KeywordPath() []string { + return nil +} + +func (p parameterStubErrorKind) LocalizedString(_ *message.Printer) string { + return p.msg +} + func Test_ForceCompilerError(t *testing.T) { // Try to force a panic result := ValidateSingleParameterSchema(nil, nil, "", "", "", "", "", nil, "", "") @@ -244,6 +257,42 @@ components: assert.Contains(t, validationErrors[0].SchemaValidationErrors[0].ReferenceSchema, "type: string") } +func TestFormatJsonSchemaValidationError_IgnoresSchemaNoise(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Filter: + type: string +`) + + doc, err := libopenapi.NewDocument(spec) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Filter").Schema() + + validationErrors := formatJsonSchemaValidationError( + schema, + &jsonschema.ValidationError{ + ErrorKind: parameterStubErrorKind{msg: "validation failed"}, + }, + "query", + "query parameter", + "filter", + helpers.ParameterValidation, + helpers.Query, + "", + "", + "", + ) + + require.Len(t, validationErrors, 1) + assert.Empty(t, validationErrors[0].SchemaValidationErrors) +} + func TestHeaderSchemaNoType(t *testing.T) { bytes := []byte(`{ "openapi": "3.0.0", diff --git a/requests/validate_request_test.go b/requests/validate_request_test.go index 6946b23f..7d23667f 100644 --- a/requests/validate_request_test.go +++ b/requests/validate_request_test.go @@ -19,8 +19,10 @@ import ( "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" + "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" liberrors "github.com/pb33f/libopenapi-validator/errors" + validatorhelpers "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/schema_validation" ) @@ -329,6 +331,82 @@ properties: assert.Contains(t, errors[0].Message, "request body is empty") } +func TestValidateRequestSchema_CachedSchemaWithoutRenderedNodeFallsBackToRenderedBytes(t *testing.T) { + schema := parseSchemaFromSpec(t, `anyOf: + - type: string + - type: integer`, 3.1) + + opts := config.NewValidationOptions() + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + schema_validation.SchemaValidationPurposeRequestBody, + opts, + 3.1, + ) + require.NoError(t, err) + + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + 3.1, + schema_validation.SchemaValidationPurposeRequestBody, + ) + entry := compiled.ToCacheEntry(schema) + entry.RenderedNode = nil + opts.SchemaCache.Store(hash, entry) + + valid, errors := ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(`true`), + Schema: schema, + Version: 3.1, + Options: []config.Option{ + config.WithExistingOpts(opts), + }, + }) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Len(t, errors[0].SchemaValidationErrors, 2) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "got boolean") +} + +func TestValidateRequestSchema_IgnoresEmptyKeywordLocationErrors(t *testing.T) { + schema := parseSchemaFromSpec(t, `type: object`, 3.1) + opts := config.NewValidationOptions() + compiledSchema, err := validatorhelpers.NewCompiledSchemaWithVersion( + "schema", + []byte(`false`), + opts, + 3.1, + ) + require.NoError(t, err) + + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + 3.1, + schema_validation.SchemaValidationPurposeRequestBody, + ) + opts.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ + Schema: schema, + RenderedInline: []byte("false"), + ReferenceSchema: "false", + RenderedJSON: []byte("false"), + CompiledSchema: compiledSchema, + }) + + valid, errors := ValidateRequestSchema(&ValidateRequestSchemaInput{ + Request: postRequestWithBody(`{"name":"test"}`), + Schema: schema, + Version: 3.1, + Options: []config.Option{ + config.WithExistingOpts(opts), + }, + }) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Empty(t, errors[0].SchemaValidationErrors) +} + func postRequestWithBody(payload string) *http.Request { return &http.Request{ Method: http.MethodPost, diff --git a/responses/validate_response_test.go b/responses/validate_response_test.go index a1ae5ca7..2de7d36f 100644 --- a/responses/validate_response_test.go +++ b/responses/validate_response_test.go @@ -1,3 +1,6 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + package responses import ( @@ -13,7 +16,9 @@ import ( "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" + "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" + validatorhelpers "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/schema_validation" ) @@ -452,3 +457,95 @@ func TestValidateResponseSchema_HeadWithBodyFails(t *testing.T) { require.Len(t, errs, 1) assert.Contains(t, errs[0].Reason, "must not contain a body") } + +func TestValidateResponseSchema_EmptyBodySkipsValidation(t *testing.T) { + schema := parseSchemaFromSpec(t, `type: object`, 3.1) + + valid, errs := ValidateResponseSchema(&ValidateResponseSchemaInput{ + Request: postRequest(), + Response: responseWithBody(""), + Schema: schema, + Version: 3.1, + }) + + assert.True(t, valid) + assert.Empty(t, errs) +} + +func TestValidateResponseSchema_CachedSchemaWithoutRenderedNodeFallsBackToRenderedBytes(t *testing.T) { + schema := parseSchemaFromSpec(t, `anyOf: + - type: string + - type: integer`, 3.1) + + opts := config.NewValidationOptions() + compiled, err := schema_validation.CompileSchemaForValidation( + schema, + schema_validation.SchemaValidationPurposeResponseBody, + opts, + 3.1, + ) + require.NoError(t, err) + + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + 3.1, + schema_validation.SchemaValidationPurposeResponseBody, + ) + entry := compiled.ToCacheEntry(schema) + entry.RenderedNode = nil + opts.SchemaCache.Store(hash, entry) + + valid, errors := ValidateResponseSchema(&ValidateResponseSchemaInput{ + Request: postRequest(), + Response: responseWithBody(`true`), + Schema: schema, + Version: 3.1, + Options: []config.Option{ + config.WithExistingOpts(opts), + }, + }) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Len(t, errors[0].SchemaValidationErrors, 2) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "got boolean") +} + +func TestValidateResponseSchema_IgnoresEmptyKeywordLocationErrors(t *testing.T) { + schema := parseSchemaFromSpec(t, `type: object`, 3.1) + opts := config.NewValidationOptions() + compiledSchema, err := validatorhelpers.NewCompiledSchemaWithVersion( + "schema", + []byte(`false`), + opts, + 3.1, + ) + require.NoError(t, err) + + hash := schema_validation.SchemaCacheKey( + schema.GoLow().Hash(), + 3.1, + schema_validation.SchemaValidationPurposeResponseBody, + ) + opts.SchemaCache.Store(hash, &cache.SchemaCacheEntry{ + Schema: schema, + RenderedInline: []byte("false"), + ReferenceSchema: "false", + RenderedJSON: []byte("false"), + CompiledSchema: compiledSchema, + }) + + valid, errors := ValidateResponseSchema(&ValidateResponseSchemaInput{ + Request: postRequest(), + Response: responseWithBody(`{"name":"test"}`), + Schema: schema, + Version: 3.1, + Options: []config.Option{ + config.WithExistingOpts(opts), + }, + }) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Empty(t, errors[0].SchemaValidationErrors) +} diff --git a/schema_validation/schema_resources.go b/schema_validation/schema_resources.go index 6ddfc615..07f238da 100644 --- a/schema_validation/schema_resources.go +++ b/schema_validation/schema_resources.go @@ -23,6 +23,11 @@ import ( const syntheticSchemaResourceBase = "https://libopenapi-validator.local/schema/" +// renderSchemaWithRefs keeps fallback rendering testable without depending on libopenapi internals failing. +var renderSchemaWithRefs = func(schema *base.Schema) ([]byte, error) { + return schema.Render() +} + // CompiledValidationSchema contains a schema compiled for validation plus the rendered schema context. type CompiledValidationSchema struct { RenderedInline []byte @@ -184,7 +189,7 @@ func renderRootSchemaForValidation(schema *base.Schema, purpose SchemaValidation return rendered, nil } - renderedInline, err := schema.Render() + renderedInline, err := renderSchemaWithRefs(schema) if err != nil { return nil, err } @@ -333,39 +338,45 @@ func addReachableSchemaResources( if foundRef == nil { continue } - if foundIndex == nil { - foundIndex = foundRef.Index - } - if foundIndex == nil || foundIndex.GetRootNode() == nil { - continue - } + foundIndex = schemaResourceIndex(foundRef, foundIndex) + if foundIndex != nil && foundIndex.GetRootNode() != nil { + resourceName := ensureSchemaResourceName(state, foundIndex, uint64(len(resourceSet.resources)+1)) + refKey := resourceName + "|" + foundRef.FullDefinition + if _, seen := state.seenRefs[refKey]; seen { + continue + } + state.seenRefs[refKey] = struct{}{} + + if _, exists := resourceSet.resources[resourceName]; !exists { + if _, err := addSchemaDocumentResource( + resourceSet.resources, + resourceSet.resourceNodes, + resourceName, + foundIndex.GetRootNode(), + purpose, + ); err != nil { + return err + } + } - resourceName := ensureSchemaResourceName(state, foundIndex, uint64(len(resourceSet.resources)+1)) - refKey := resourceName + "|" + foundRef.FullDefinition - if _, seen := state.seenRefs[refKey]; seen { - continue - } - state.seenRefs[refKey] = struct{}{} - - if _, exists := resourceSet.resources[resourceName]; !exists { - if _, err := addSchemaDocumentResource( - resourceSet.resources, - resourceSet.resourceNodes, - resourceName, - foundIndex.GetRootNode(), - purpose, - ); err != nil { + if err := addReachableSchemaResources(resourceSet, state, foundIndex, foundRef.Node, purpose); err != nil { return err } } - - if err := addReachableSchemaResources(resourceSet, state, foundIndex, foundRef.Node, purpose); err != nil { - return err - } } return nil } +func schemaResourceIndex(foundRef *index.Reference, foundIndex *index.SpecIndex) *index.SpecIndex { + if foundIndex != nil { + return foundIndex + } + if foundRef == nil { + return nil + } + return foundRef.Index +} + // ensureSchemaResourceName returns the stable compiler resource name for a parsed document. func ensureSchemaResourceName(state *schemaResourceBuildState, schemaIndex *index.SpecIndex, fallback uint64) string { if state == nil || schemaIndex == nil { @@ -537,9 +548,6 @@ func jsonPointerForNode(rootNode, targetNode *yaml.Node) (string, bool) { if !ok { return "", false } - if len(tokens) == 0 { - return "", true - } return "/" + strings.Join(tokens, "/"), true } diff --git a/schema_validation/schema_resources_test.go b/schema_validation/schema_resources_test.go index 014207b9..628f2a93 100644 --- a/schema_validation/schema_resources_test.go +++ b/schema_validation/schema_resources_test.go @@ -185,6 +185,39 @@ components: }) } +func TestRenderRootSchemaForValidation_RenderFallbackFailure(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Node: + type: object + properties: + child: + $ref: '#/components/schemas/Node'` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + schema := model.Model.Components.Schemas.GetOrZero("Node").Schema() + + originalRenderSchemaWithRefs := renderSchemaWithRefs + renderSchemaWithRefs = func(*base.Schema) ([]byte, error) { + return nil, assert.AnError + } + t.Cleanup(func() { + renderSchemaWithRefs = originalRenderSchemaWithRefs + }) + + rendered, err := renderRootSchemaForValidation(schema, SchemaValidationPurposeGeneric) + + assert.Nil(t, rendered) + require.ErrorIs(t, err, assert.AnError) +} + func TestSingleSchemaCompilePreferred_LocalReferenceSchemaUsesResourceCompiler(t *testing.T) { spec := `openapi: 3.1.0 info: @@ -587,6 +620,23 @@ func TestAddReachableSchemaResources_GuardsAndSkips(t *testing.T) { )) } +func TestSchemaResourceIndex_Fallbacks(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`type: object`), &root)) + preferredIndex := index.NewSpecIndex(&root) + fallbackIndex := index.NewSpecIndex(&root) + + assert.Nil(t, schemaResourceIndex(nil, nil)) + assert.Same(t, preferredIndex, schemaResourceIndex( + &index.Reference{Index: fallbackIndex}, + preferredIndex, + )) + assert.Same(t, fallbackIndex, schemaResourceIndex( + &index.Reference{Index: fallbackIndex}, + nil, + )) +} + func TestAppendJSONPointerTokensForNode_DocumentNodeDirectCall(t *testing.T) { var root yaml.Node require.NoError(t, yaml.Unmarshal([]byte(`items: diff --git a/schema_validation/validate_schema_extract_errors_test.go b/schema_validation/validate_schema_extract_errors_test.go index 29c1c263..2572931d 100644 --- a/schema_validation/validate_schema_extract_errors_test.go +++ b/schema_validation/validate_schema_extract_errors_test.go @@ -136,3 +136,25 @@ func TestExtractBasicErrors_UsesExternalResourceNodeForAbsoluteKeywordLocation(t assert.Equal(t, located.Line, failures[0].Line) assert.Equal(t, located.Column, failures[0].Column) } + +func TestExtractBasicErrors_IgnoresGenericSchemaNoise(t *testing.T) { + failures := extractBasicErrors( + []jsonschema.OutputUnit{ + { + KeywordLocation: "/anyOf", + Error: &jsonschema.OutputError{ + Kind: stubErrorKind{msg: "anyOf failed, none matched"}, + }, + }, + }, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + ) + + assert.Empty(t, failures) +}