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
91 changes: 91 additions & 0 deletions libs/jsonschema/instance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package jsonschema

import (
"encoding/json"
"fmt"
"os"
)

// Load a JSON document and validate it against the JSON schema. Instance here
// refers to a JSON document. see: https://json-schema.org/draft/2020-12/json-schema-core.html#name-instance
func (s *Schema) LoadInstance(path string) (map[string]any, error) {
instance := make(map[string]any)
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &instance)
if err != nil {
return nil, err
}

// The default JSON unmarshaler parses untyped number values as float64.
// We convert integer properties from float64 to int64 here.
Comment thread
shreyas-goenka marked this conversation as resolved.
for name, v := range instance {
propertySchema, ok := s.Properties[name]
if !ok {
continue
}
if propertySchema.Type != IntegerType {
continue
}
integerValue, err := toInteger(v)
if err != nil {
return nil, fmt.Errorf("failed to parse property %s: %w", name, err)
}
instance[name] = integerValue
}
return instance, s.ValidateInstance(instance)
}

func (s *Schema) ValidateInstance(instance map[string]any) error {
if err := s.validateAdditionalProperties(instance); err != nil {
return err
}
if err := s.validateRequired(instance); err != nil {
return err
}
return s.validateTypes(instance)
}

// If additional properties is set to false, this function validates instance only
// contains properties defined in the schema.
func (s *Schema) validateAdditionalProperties(instance map[string]any) error {
// Note: AdditionalProperties has the type any.
if s.AdditionalProperties != false {
Comment thread
shreyas-goenka marked this conversation as resolved.
return nil
}
for k := range instance {
_, ok := s.Properties[k]
if !ok {
return fmt.Errorf("property %s is not defined in the schema", k)
}
}
return nil
}

// This function validates that all require properties in the schema have values
// in the instance.
func (s *Schema) validateRequired(instance map[string]any) error {
for _, name := range s.Required {
if _, ok := instance[name]; !ok {
return fmt.Errorf("no value provided for required property %s", name)
}
}
return nil
}

// Validates the types of all input properties values match their types defined in the schema
func (s *Schema) validateTypes(instance map[string]any) error {
for k, v := range instance {
fieldInfo, ok := s.Properties[k]
if !ok {
continue
}
err := validateType(v, fieldInfo.Type)
if err != nil {
return fmt.Errorf("incorrect type for property %s: %w", k, err)
}
}
return nil
}
129 changes: 129 additions & 0 deletions libs/jsonschema/instance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package jsonschema

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateInstanceAdditionalPropertiesPermitted(t *testing.T) {
instance := map[string]any{
"int_val": 1,
"float_val": 1.0,
"bool_val": false,
"an_additional_property": "abc",
}

schema, err := Load("./testdata/instance-validate/test-schema.json")
require.NoError(t, err)

err = schema.validateAdditionalProperties(instance)
assert.NoError(t, err)

err = schema.ValidateInstance(instance)
assert.NoError(t, err)
}

func TestValidateInstanceAdditionalPropertiesForbidden(t *testing.T) {
instance := map[string]any{
"int_val": 1,
"float_val": 1.0,
"bool_val": false,
"an_additional_property": "abc",
}

schema, err := Load("./testdata/instance-validate/test-schema-no-additional-properties.json")
require.NoError(t, err)

err = schema.validateAdditionalProperties(instance)
assert.EqualError(t, err, "property an_additional_property is not defined in the schema")

err = schema.ValidateInstance(instance)
assert.EqualError(t, err, "property an_additional_property is not defined in the schema")

instanceWOAdditionalProperties := map[string]any{
"int_val": 1,
"float_val": 1.0,
"bool_val": false,
}

err = schema.validateAdditionalProperties(instanceWOAdditionalProperties)
assert.NoError(t, err)

err = schema.ValidateInstance(instanceWOAdditionalProperties)
assert.NoError(t, err)
}

func TestValidateInstanceTypes(t *testing.T) {
schema, err := Load("./testdata/instance-validate/test-schema.json")
require.NoError(t, err)

validInstance := map[string]any{
"int_val": 1,
"float_val": 1.0,
"bool_val": false,
}

err = schema.validateTypes(validInstance)
assert.NoError(t, err)

err = schema.ValidateInstance(validInstance)
assert.NoError(t, err)

invalidInstance := map[string]any{
"int_val": "abc",
"float_val": 1.0,
"bool_val": false,
}

err = schema.validateTypes(invalidInstance)
assert.EqualError(t, err, "incorrect type for property int_val: expected type integer, but value is \"abc\"")

err = schema.ValidateInstance(invalidInstance)
assert.EqualError(t, err, "incorrect type for property int_val: expected type integer, but value is \"abc\"")
}

func TestValidateInstanceRequired(t *testing.T) {
schema, err := Load("./testdata/instance-validate/test-schema-some-fields-required.json")
require.NoError(t, err)

validInstance := map[string]any{
"int_val": 1,
"float_val": 1.0,
"bool_val": false,
}
err = schema.validateRequired(validInstance)
assert.NoError(t, err)
err = schema.ValidateInstance(validInstance)
assert.NoError(t, err)

invalidInstance := map[string]any{
"string_val": "abc",
"float_val": 1.0,
"bool_val": false,
}
err = schema.validateRequired(invalidInstance)
assert.EqualError(t, err, "no value provided for required property int_val")
err = schema.ValidateInstance(invalidInstance)
assert.EqualError(t, err, "no value provided for required property int_val")
}

func TestLoadInstance(t *testing.T) {
schema, err := Load("./testdata/instance-validate/test-schema.json")
require.NoError(t, err)

// Expect the instance to be loaded successfully.
instance, err := schema.LoadInstance("./testdata/instance-load/valid-instance.json")
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"bool_val": false,
"int_val": int64(1),
"string_val": "abc",
"float_val": 2.0,
}, instance)

// Expect instance validation against the schema to fail.
_, err = schema.LoadInstance("./testdata/instance-load/invalid-type-instance.json")
assert.EqualError(t, err, "incorrect type for property string_val: expected type string, but value is 123")
}
32 changes: 32 additions & 0 deletions libs/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const (
)

func (schema *Schema) validate() error {
// Validate property types are all valid JSON schema types.
for _, v := range schema.Properties {
switch v.Type {
case NumberType, BooleanType, StringType, IntegerType:
Expand All @@ -72,6 +73,17 @@ func (schema *Schema) validate() error {
return fmt.Errorf("type %s is not a recognized json schema type", v.Type)
}
}

// Validate default property values are consistent with types.
for name, property := range schema.Properties {
if property.Default == nil {
continue
}
if err := validateType(property.Default, property.Type); err != nil {
return fmt.Errorf("type validation for default value of property %s failed: %w", name, err)
}
}

return nil
}

Expand All @@ -85,5 +97,25 @@ func Load(path string) (*Schema, error) {
if err != nil {
return nil, err
}

// Convert the default values of top-level properties to integers.
// This is required because the default JSON unmarshaler parses numbers
// as floats when the Golang field it's being loaded to is untyped.
//
// NOTE: properties can be recursively defined in a schema, but the current
// use-cases only uses the first layer of properties so we skip converting
// any recursive properties.
for name, property := range schema.Properties {
if property.Type != IntegerType {
continue
}
if property.Default != nil {
property.Default, err = toInteger(property.Default)
if err != nil {
return nil, fmt.Errorf("failed to parse default value for property %s: %w", name, err)
}
}
}

return schema, schema.validate()
}
39 changes: 38 additions & 1 deletion libs/jsonschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)

func TestJsonSchemaValidate(t *testing.T) {
func TestSchemaValidateTypeNames(t *testing.T) {
var err error
toSchema := func(s string) *Schema {
return &Schema{
Expand Down Expand Up @@ -42,3 +42,40 @@ func TestJsonSchemaValidate(t *testing.T) {
err = toSchema("foobar").validate()
assert.EqualError(t, err, "type foobar is not a recognized json schema type")
}

func TestSchemaLoadIntegers(t *testing.T) {
schema, err := Load("./testdata/schema-load-int/schema-valid.json")
assert.NoError(t, err)
assert.Equal(t, int64(1), schema.Properties["abc"].Default)
}

func TestSchemaLoadIntegersWithInvalidDefault(t *testing.T) {
_, err := Load("./testdata/schema-load-int/schema-invalid-default.json")
assert.EqualError(t, err, "failed to parse default value for property abc: expected integer value, got: 1.1")
}

func TestSchemaValidateDefaultType(t *testing.T) {
invalidSchema := &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "number",
Default: "abc",
},
},
}

err := invalidSchema.validate()
assert.EqualError(t, err, "type validation for default value of property foo failed: expected type float, but value is \"abc\"")

validSchema := &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "boolean",
Default: true,
},
},
}

err = validSchema.validate()
assert.NoError(t, err)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"int_val": 1,
"bool_val": false,
"string_val": 123,
"float_val": 3.0
}
6 changes: 6 additions & 0 deletions libs/jsonschema/testdata/instance-load/valid-instance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"int_val": 1,
"bool_val": false,
"string_val": "abc",
"float_val": 2.0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"properties": {
"int_val": {
"type": "integer",
"default": 123
},
"float_val": {
"type": "number"
},
"bool_val": {
"type": "boolean"
},
"string_val": {
"type": "string",
"default": "abc"
}
},
"additionalProperties": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"properties": {
"int_val": {
"type": "integer",
"default": 123
},
"float_val": {
"type": "number"
},
"bool_val": {
"type": "boolean"
},
"string_val": {
"type": "string",
"default": "abc"
}
},
"required": ["int_val", "float_val", "bool_val"]
}
18 changes: 18 additions & 0 deletions libs/jsonschema/testdata/instance-validate/test-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"properties": {
"int_val": {
"type": "integer",
"default": 123
},
"float_val": {
"type": "number"
},
"bool_val": {
"type": "boolean"
},
"string_val": {
"type": "string",
"default": "abc"
}
}
}
Loading