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
36 changes: 29 additions & 7 deletions jsonschema/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
AdditionalProperties any `json:"additionalProperties,omitempty"`
// Whether the schema is nullable or not.
Nullable bool `json:"nullable,omitempty"`

// Ref Reference to a definition in $defs or external schema.
Ref string `json:"$ref,omitempty"`
// Defs A map of reusable schema definitions.
Defs map[string]Definition `json:"$defs,omitempty"`
}

func (d *Definition) MarshalJSON() ([]byte, error) {
Expand All @@ -67,10 +72,16 @@
}

func GenerateSchemaForType(v any) (*Definition, error) {
return reflectSchema(reflect.TypeOf(v))
var defs = make(map[string]Definition)
def, err := reflectSchema(reflect.TypeOf(v), defs)
if err != nil {
return nil, err
}

Check warning on line 79 in jsonschema/json.go

View check run for this annotation

Codecov / codecov/patch

jsonschema/json.go#L78-L79

Added lines #L78 - L79 were not covered by tests
def.Defs = defs
return def, nil
}

func reflectSchema(t reflect.Type) (*Definition, error) {
func reflectSchema(t reflect.Type, defs map[string]Definition) (*Definition, error) {
var d Definition
switch t.Kind() {
case reflect.String:
Expand All @@ -84,21 +95,32 @@
d.Type = Boolean
case reflect.Slice, reflect.Array:
d.Type = Array
items, err := reflectSchema(t.Elem())
items, err := reflectSchema(t.Elem(), defs)
if err != nil {
return nil, err
}
d.Items = items
case reflect.Struct:
if t.Name() != "" {
if _, ok := defs[t.Name()]; !ok {
defs[t.Name()] = Definition{}
object, err := reflectSchemaObject(t, defs)
if err != nil {
return nil, err
}

Check warning on line 110 in jsonschema/json.go

View check run for this annotation

Codecov / codecov/patch

jsonschema/json.go#L109-L110

Added lines #L109 - L110 were not covered by tests
defs[t.Name()] = *object
}
return &Definition{Ref: "#/$defs/" + t.Name()}, nil
}
d.Type = Object
d.AdditionalProperties = false
object, err := reflectSchemaObject(t)
object, err := reflectSchemaObject(t, defs)
if err != nil {
return nil, err
}
d = *object
case reflect.Ptr:
definition, err := reflectSchema(t.Elem())
definition, err := reflectSchema(t.Elem(), defs)
if err != nil {
return nil, err
}
Expand All @@ -112,7 +134,7 @@
return &d, nil
}

func reflectSchemaObject(t reflect.Type) (*Definition, error) {
func reflectSchemaObject(t reflect.Type, defs map[string]Definition) (*Definition, error) {
var d = Definition{
Type: Object,
AdditionalProperties: false,
Expand All @@ -136,7 +158,7 @@
required = false
}

item, err := reflectSchema(field.Type)
item, err := reflectSchema(field.Type, defs)
if err != nil {
return nil, err
}
Expand Down
70 changes: 70 additions & 0 deletions jsonschema/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ func TestDefinition_MarshalJSON(t *testing.T) {
}

func TestStructToSchema(t *testing.T) {
type Tweet struct {
Text string `json:"text"`
}

type Person struct {
Name string `json:"name,omitempty"`
Age int `json:"age,omitempty"`
Friends []Person `json:"friends,omitempty"`
Tweets []Tweet `json:"tweets,omitempty"`
}

tests := []struct {
name string
in any
Expand Down Expand Up @@ -376,6 +387,65 @@ func TestStructToSchema(t *testing.T) {
"additionalProperties":false
}`,
},
{
name: "Test with $ref and $defs",
in: struct {
Person Person `json:"person"`
Tweets []Tweet `json:"tweets"`
}{},
want: `{
"type" : "object",
"properties" : {
"person" : {
"$ref" : "#/$defs/Person"
},
"tweets" : {
"type" : "array",
"items" : {
"$ref" : "#/$defs/Tweet"
}
}
},
"required" : [ "person", "tweets" ],
"additionalProperties" : false,
"$defs" : {
"Person" : {
"type" : "object",
"properties" : {
"age" : {
"type" : "integer"
},
"friends" : {
"type" : "array",
"items" : {
"$ref" : "#/$defs/Person"
}
},
"name" : {
"type" : "string"
},
"tweets" : {
"type" : "array",
"items" : {
"$ref" : "#/$defs/Tweet"
}
}
},
"additionalProperties" : false
},
"Tweet" : {
"type" : "object",
"properties" : {
"text" : {
"type" : "string"
}
},
"required" : [ "text" ],
"additionalProperties" : false
}
}
}`,
},
}

for _, tt := range tests {
Expand Down
65 changes: 56 additions & 9 deletions jsonschema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,68 @@ import (
"errors"
)

func CollectDefs(def Definition) map[string]Definition {
result := make(map[string]Definition)
collectDefsRecursive(def, result, "#")
return result
}

func collectDefsRecursive(def Definition, result map[string]Definition, prefix string) {
for k, v := range def.Defs {
path := prefix + "/$defs/" + k
result[path] = v
collectDefsRecursive(v, result, path)
}
for k, sub := range def.Properties {
collectDefsRecursive(sub, result, prefix+"/properties/"+k)
}
if def.Items != nil {
collectDefsRecursive(*def.Items, result, prefix)
}
}

func VerifySchemaAndUnmarshal(schema Definition, content []byte, v any) error {
var data any
err := json.Unmarshal(content, &data)
if err != nil {
return err
}
if !Validate(schema, data) {
if !Validate(schema, data, WithDefs(CollectDefs(schema))) {
return errors.New("data validation failed against the provided schema")
}
return json.Unmarshal(content, &v)
}

func Validate(schema Definition, data any) bool {
type validateArgs struct {
Defs map[string]Definition
}

type ValidateOption func(*validateArgs)

func WithDefs(defs map[string]Definition) ValidateOption {
return func(option *validateArgs) {
option.Defs = defs
}
}

func Validate(schema Definition, data any, opts ...ValidateOption) bool {
args := validateArgs{}
for _, opt := range opts {
opt(&args)
}
if len(opts) == 0 {
args.Defs = CollectDefs(schema)
}
switch schema.Type {
case Object:
return validateObject(schema, data)
return validateObject(schema, data, args.Defs)
case Array:
return validateArray(schema, data)
return validateArray(schema, data, args.Defs)
case String:
_, ok := data.(string)
v, ok := data.(string)
if ok && len(schema.Enum) > 0 {
return contains(schema.Enum, v)
}
return ok
case Number: // float64 and int
_, ok := data.(float64)
Expand All @@ -45,11 +87,16 @@ func Validate(schema Definition, data any) bool {
case Null:
return data == nil
default:
if schema.Ref != "" && args.Defs != nil {
if v, ok := args.Defs[schema.Ref]; ok {
return Validate(v, data, WithDefs(args.Defs))
}
}
return false
}
}

func validateObject(schema Definition, data any) bool {
func validateObject(schema Definition, data any, defs map[string]Definition) bool {
dataMap, ok := data.(map[string]any)
if !ok {
return false
Expand All @@ -61,7 +108,7 @@ func validateObject(schema Definition, data any) bool {
}
for key, valueSchema := range schema.Properties {
value, exists := dataMap[key]
if exists && !Validate(valueSchema, value) {
if exists && !Validate(valueSchema, value, WithDefs(defs)) {
return false
} else if !exists && contains(schema.Required, key) {
return false
Expand All @@ -70,13 +117,13 @@ func validateObject(schema Definition, data any) bool {
return true
}

func validateArray(schema Definition, data any) bool {
func validateArray(schema Definition, data any, defs map[string]Definition) bool {
dataArray, ok := data.([]any)
if !ok {
return false
}
for _, item := range dataArray {
if !Validate(*schema.Items, item) {
if !Validate(*schema.Items, item, WithDefs(defs)) {
return false
}
}
Expand Down
Loading
Loading