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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cache/cache.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
113 changes: 80 additions & 33 deletions helpers/schema_compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"sort"

"github.com/santhosh-tekuri/jsonschema/v6"

Expand Down Expand Up @@ -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 {
Expand All @@ -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"
Expand All @@ -111,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
}

Expand Down Expand Up @@ -273,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
}

Expand Down
85 changes: 85 additions & 0 deletions helpers/schema_compiler_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT

package helpers

import (
Expand Down Expand Up @@ -46,6 +49,88 @@ 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 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",
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)
Expand Down
37 changes: 37 additions & 0 deletions helpers/schema_output.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions helpers/schema_output_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading