diff --git a/jsonschema/doc.go b/jsonschema/doc.go index 0f0ba44..553e2a9 100644 --- a/jsonschema/doc.go +++ b/jsonschema/doc.go @@ -6,8 +6,8 @@ Package jsonschema is an implementation of the [JSON Schema specification], a JSON-based format for describing the structure of JSON data. The package can be used to read schemas for code generation, and to validate -data using the draft 2020-12 specification. Validation with other drafts -or custom meta-schemas is not supported. +data using the draft 2020-12 and draft-07 specifications. Validation with +other drafts or custom meta-schemas is not supported. Construct a [Schema] as you would any Go struct (for example, by writing a struct literal), or unmarshal a JSON schema into a [Schema] in the usual diff --git a/jsonschema/draft07_test.go b/jsonschema/draft07_test.go new file mode 100644 index 0000000..89560b1 --- /dev/null +++ b/jsonschema/draft07_test.go @@ -0,0 +1,527 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package jsonschema + +import ( + "encoding/json" + "testing" +) + +// TestDraft07Schema tests draft-07 specific schema behaviors +func TestDraft07Schema(t *testing.T) { + tests := []struct { + name string + schema string + data string + valid bool + }{ + { + name: "draft-07 schema version", + schema: `{"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}`, + data: `"hello"`, + valid: true, + }, + { + name: "draft-07 schema version with https", + schema: `{"$schema": "https://json-schema.org/draft-07/schema#", "type": "string"}`, + data: `"hello"`, + valid: true, + }, + { + name: "invalid data against draft-07 schema", + schema: `{"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}`, + data: `123`, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schema Schema + if err := json.Unmarshal([]byte(tt.schema), &schema); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + var data interface{} + if err := json.Unmarshal([]byte(tt.data), &data); err != nil { + t.Fatalf("failed to unmarshal data: %v", err) + } + + rs, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + + err = rs.Validate(data) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } else if !tt.valid && err == nil { + t.Errorf("expected invalid, got no error") + } + }) + } +} + +// TestDraft07Dependencies tests draft-07 specific dependencies behavior +func TestDraft07Dependencies(t *testing.T) { + tests := []struct { + name string + schema string + data string + valid bool + }{ + { + name: "draft-07 property dependencies", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "dependencies": { + "billing_address": ["credit_card"] + } + }`, + data: `{"billing_address": "123 Main St"}`, + valid: false, + }, + { + name: "draft-07 property dependencies satisfied", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "dependencies": { + "billing_address": ["credit_card"] + } + }`, + data: `{"billing_address": "123 Main St", "credit_card": "1234"}`, + valid: true, + }, + { + name: "draft-07 schema dependencies", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "dependencies": { + "billing_address": { + "properties": { + "credit_card": {"type": "string"} + }, + "required": ["credit_card"] + } + } + }`, + data: `{"billing_address": "123 Main St"}`, + valid: false, + }, + { + name: "draft-07 schema dependencies satisfied", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "dependencies": { + "billing_address": { + "properties": { + "credit_card": {"type": "string"} + }, + "required": ["credit_card"] + } + } + }`, + data: `{"billing_address": "123 Main St", "credit_card": "1234"}`, + valid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schema Schema + if err := json.Unmarshal([]byte(tt.schema), &schema); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + var data interface{} + if err := json.Unmarshal([]byte(tt.data), &data); err != nil { + t.Fatalf("failed to unmarshal data: %v", err) + } + + rs, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + + err = rs.Validate(data) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } else if !tt.valid && err == nil { + t.Errorf("expected invalid, got no error") + } + }) + } +} + +// TestDraft07ItemsArray tests draft-07 items array behavior (tuple validation) +func TestDraft07ItemsArray(t *testing.T) { + tests := []struct { + name string + schema string + data string + valid bool + }{ + { + name: "draft-07 items array - valid tuple", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ] + }`, + data: `["hello", 42]`, + valid: true, + }, + { + name: "draft-07 items array - invalid first element", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ] + }`, + data: `[123, 42]`, + valid: false, + }, + { + name: "draft-07 items array - invalid second element", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ] + }`, + data: `["hello", "world"]`, + valid: false, + }, + { + name: "draft-07 items array with additionalItems", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ], + "additionalItems": {"type": "boolean"} + }`, + data: `["hello", 42, true]`, + valid: true, + }, + { + name: "draft-07 items array with invalid additionalItems", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ], + "additionalItems": {"type": "boolean"} + }`, + data: `["hello", 42, "extra"]`, + valid: false, + }, + { + name: "draft-07 items array with additionalItems false", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": [ + {"type": "string"}, + {"type": "number"} + ], + "additionalItems": false + }`, + data: `["hello", 42, true]`, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schema Schema + if err := json.Unmarshal([]byte(tt.schema), &schema); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + var data interface{} + if err := json.Unmarshal([]byte(tt.data), &data); err != nil { + t.Fatalf("failed to unmarshal data: %v", err) + } + + rs, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + + err = rs.Validate(data) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } else if !tt.valid && err == nil { + t.Errorf("expected invalid, got no error") + } + }) + } +} + +// TestDraft07Definitions tests draft-07 definitions keyword behavior +func TestDraft07Definitions(t *testing.T) { + schema := `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "definitions": { + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + }, + "required": ["street", "city"] + } + }, + "properties": { + "home": {"$ref": "#/definitions/address"}, + "work": {"$ref": "#/definitions/address"} + } + }` + + tests := []struct { + name string + data string + valid bool + }{ + { + name: "valid addresses", + data: `{ + "home": {"street": "123 Main St", "city": "Anytown"}, + "work": {"street": "456 Oak Ave", "city": "Other City"} + }`, + valid: true, + }, + { + name: "invalid home address", + data: `{ + "home": {"street": "123 Main St"}, + "work": {"street": "456 Oak Ave", "city": "Other City"} + }`, + valid: false, + }, + { + name: "invalid work address", + data: `{ + "home": {"street": "123 Main St", "city": "Anytown"}, + "work": {"street": "456 Oak Ave"} + }`, + valid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schemaObj Schema + if err := json.Unmarshal([]byte(schema), &schemaObj); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + var data interface{} + if err := json.Unmarshal([]byte(tt.data), &data); err != nil { + t.Fatalf("failed to unmarshal data: %v", err) + } + + rs, err := schemaObj.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + + err = rs.Validate(data) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } else if !tt.valid && err == nil { + t.Errorf("expected invalid, got no error") + } + }) + } +} + +// TestDraft07BooleanSchemas tests draft-07 boolean schema behavior +func TestDraft07BooleanSchemas(t *testing.T) { + tests := []struct { + name string + schema string + data string + valid bool + }{ + { + name: "true schema allows everything", + schema: `true`, + data: `"anything"`, + valid: true, + }, + { + name: "true schema allows numbers", + schema: `true`, + data: `42`, + valid: true, + }, + { + name: "true schema allows objects", + schema: `true`, + data: `{"key": "value"}`, + valid: true, + }, + { + name: "false schema rejects everything", + schema: `false`, + data: `"anything"`, + valid: false, + }, + { + name: "false schema rejects numbers", + schema: `false`, + data: `42`, + valid: false, + }, + { + name: "false schema rejects objects", + schema: `false`, + data: `{"key": "value"}`, + valid: false, + }, + { + name: "boolean schema in object properties", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "always_valid": true, + "never_valid": false + } + }`, + data: `{"always_valid": "anything", "never_valid": "something"}`, + valid: false, + }, + { + name: "boolean schema in object properties - valid case", + schema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "always_valid": true + } + }`, + data: `{"always_valid": "anything"}`, + valid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schema Schema + if err := json.Unmarshal([]byte(tt.schema), &schema); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + var data interface{} + if err := json.Unmarshal([]byte(tt.data), &data); err != nil { + t.Fatalf("failed to unmarshal data: %v", err) + } + + rs, err := schema.Resolve(nil) + if err != nil { + t.Fatalf("failed to resolve schema: %v", err) + } + + err = rs.Validate(data) + if tt.valid && err != nil { + t.Errorf("expected valid, got error: %v", err) + } else if !tt.valid && err == nil { + t.Errorf("expected invalid, got no error") + } + }) + } +} + +// TestDraft07Marshalling tests that draft-07 specific schemas marshal correctly +func TestDraft07Marshalling(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "draft-07 items array marshalling", + input: `{"items": [{"type": "string"}, {"type": "number"}]}`, + expected: `{"items":[{"type":"string"},{"type":"number"}],"prefixItems":[{"type":"string"},{"type":"number"}]}`, + }, + { + name: "draft-07 dependencies marshalling", + input: `{"dependencies": {"name": ["first", "last"]}}`, + expected: `{"dependencies":{"name":["first","last"]}}`, + }, + { + name: "draft-07 definitions marshalling", + input: `{"definitions": {"person": {"type": "object"}}}`, + expected: `{"definitions":{"person":{"type":"object"}}}`, + }, + { + name: "draft-07 schema with $schema", + input: `{"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}`, + expected: `{"type":"string","$schema":"http://json-schema.org/draft-07/schema#"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var schema Schema + if err := json.Unmarshal([]byte(tt.input), &schema); err != nil { + t.Fatalf("failed to unmarshal schema: %v", err) + } + + data, err := json.Marshal(&schema) + if err != nil { + t.Fatalf("failed to marshal schema: %v", err) + } + + if string(data) != tt.expected { + t.Errorf("marshalling mismatch:\ngot: %s\nwant: %s", string(data), tt.expected) + } + }) + } +} + +// TestDraft07VersionDetection tests the isDraft07 function +func TestDraft07VersionDetection(t *testing.T) { + tests := []struct { + version string + expected bool + }{ + {"http://json-schema.org/draft-07/schema#", true}, + {"https://json-schema.org/draft-07/schema#", true}, + {"http://json-schema.org/draft/2020-12/schema", false}, + {"https://json-schema.org/draft/2020-12/schema", false}, + {"", false}, + {"invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + result := isDraft07(tt.version) + if result != tt.expected { + t.Errorf("isDraft07(%q) = %v, want %v", tt.version, result, tt.expected) + } + }) + } +} diff --git a/jsonschema/schema.go b/jsonschema/schema.go index 9a68cd5..262c9a1 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -18,10 +18,10 @@ import ( ) // A Schema is a JSON schema object. -// It corresponds to the 2020-12 draft, as described in https://json-schema.org/draft/2020-12, -// specifically: -// - https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01 -// - https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 +// It supports both draft-07 and the 2020-12 draft specifications: +// - Draft-07: http://json-schema.org/draft-07/schema# +// - Draft 2020-12: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01 +// and https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 // // A Schema value may have non-zero values for more than one field: // all relevant non-zero fields are used for validation. @@ -102,6 +102,9 @@ type Schema struct { PropertyNames *Schema `json:"propertyNames,omitempty"` UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"` + // draft-07 specific - Dependencies field that was split in draft 2020-12 + Dependencies map[string]any `json:"dependencies,omitempty"` + // logic AllOf []*Schema `json:"allOf,omitempty"` AnyOf []*Schema `json:"anyOf,omitempty"` @@ -202,7 +205,6 @@ func (s *Schema) MarshalJSON() ([]byte, error) { if err := s.basicChecks(); err != nil { return nil, err } - // Marshal either Type or Types as "type". var typ any switch { @@ -211,11 +213,24 @@ func (s *Schema) MarshalJSON() ([]byte, error) { case s.Types != nil: typ = s.Types } + + // For draft-07 compatibility: if we only have PrefixItems and no Items, + // marshal PrefixItems as "items" array, otherwise marshal Items as single schema + var items any + if len(s.PrefixItems) > 0 && s.Items == nil { + // This looks like a draft-07 items array converted to prefixItems + items = s.PrefixItems + } else if s.Items != nil { + items = s.Items + } + ms := struct { - Type any `json:"type,omitempty"` + Type any `json:"type,omitempty"` + Items any `json:"items,omitempty"` *schemaWithoutMethods }{ Type: typ, + Items: items, schemaWithoutMethods: (*schemaWithoutMethods)(s), } bs, err := marshalStructWithMap(&ms, "Extra") @@ -249,6 +264,7 @@ func (s *Schema) UnmarshalJSON(data []byte) error { ms := struct { Type json.RawMessage `json:"type,omitempty"` + Items json.RawMessage `json:"items,omitempty"` Const json.RawMessage `json:"const,omitempty"` MinLength *integer `json:"minLength,omitempty"` MaxLength *integer `json:"maxLength,omitempty"` @@ -314,6 +330,34 @@ func (s *Schema) UnmarshalJSON(data []byte) error { set(&s.MinContains, ms.MinContains) set(&s.MaxContains, ms.MaxContains) + // Handle "items" field: can be either a schema or an array of schemas (draft-07) + if len(ms.Items) > 0 { + switch ms.Items[0] { + case '{': + // Single schema object + err = json.Unmarshal(ms.Items, &s.Items) + case '[': + // Array of schemas (draft-07 tuple validation) + // For draft-07, convert items array to prefixItems for compatibility + err = json.Unmarshal(ms.Items, &s.PrefixItems) + case 't', 'f': + // Boolean schema + var boolSchema bool + if err = json.Unmarshal(ms.Items, &boolSchema); err == nil { + if boolSchema { + s.Items = &Schema{} + } else { + s.Items = falseSchema() + } + } + default: + err = fmt.Errorf(`invalid value for "items": %q`, ms.Items) + } + if err != nil { + return err + } + } + return nil } @@ -389,6 +433,7 @@ func (s *Schema) everyChild(f func(*Schema) bool) bool { } } } + return true } diff --git a/jsonschema/validate.go b/jsonschema/validate.go index 99ddd3b..40208af 100644 --- a/jsonschema/validate.go +++ b/jsonschema/validate.go @@ -18,15 +18,34 @@ import ( "unicode/utf8" ) -// The value of the "$schema" keyword for the version that we can validate. -const draft202012 = "https://json-schema.org/draft/2020-12/schema" +// The values of the "$schema" keyword for the versions that we can validate. +const ( + draft07 = "http://json-schema.org/draft-07/schema#" + draft07Sec = "https://json-schema.org/draft-07/schema#" + draft202012 = "https://json-schema.org/draft/2020-12/schema" +) + +// isValidSchemaVersion checks if the given schema version is supported +func isValidSchemaVersion(version string) bool { + return version == "" || version == draft07 || version == draft07Sec || version == draft202012 +} + +// isDraft07 checks if the schema version is draft-07 +func isDraft07(version string) bool { + return version == draft07 || version == draft07Sec +} + +// isDraft202012 checks if the schema version is draft 2020-12 +func isDraft202012(version string) bool { + return version == "" || version == draft202012 // empty defaults to 2020-12 +} // Validate validates the instance, which must be a JSON value, against the schema. // It returns nil if validation is successful or an error if it is not. // If the schema type is "object", instance can be a map[string]any or a struct. func (rs *Resolved) Validate(instance any) error { - if s := rs.root.Schema; s != "" && s != draft202012 { - return fmt.Errorf("cannot validate version %s, only %s", s, draft202012) + if s := rs.root.Schema; !isValidSchemaVersion(s) { + return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s) } st := &state{rs: rs} return st.validate(reflect.ValueOf(instance), st.rs.root, nil) @@ -38,8 +57,8 @@ func (rs *Resolved) Validate(instance any) error { // TODO(jba): account for dynamic refs. This algorithm simple-mindedly // treats each schema with a default as its own root. func (rs *Resolved) validateDefaults() error { - if s := rs.root.Schema; s != "" && s != draft202012 { - return fmt.Errorf("cannot validate version %s, only %s", s, draft202012) + if s := rs.root.Schema; !isValidSchemaVersion(s) { + return fmt.Errorf("cannot validate version %s, supported versions: draft-07 and draft 2020-12", s) } st := &state{rs: rs} for s := range rs.root.all() { @@ -296,6 +315,8 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an // arrays // TODO(jba): consider arrays of structs. if instance.Kind() == reflect.Array || instance.Kind() == reflect.Slice { + // Handle both draft-07 and draft 2020-12 array validation using the same logic + // Draft-07 items arrays are converted to prefixItems during unmarshaling // https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.1 // This validate call doesn't collect annotations for the items of the instance; they are separate // instances in their own right. @@ -310,6 +331,8 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an } anns.noteEndIndex(min(len(schema.PrefixItems), instance.Len())) + // For draft 2020-12: items applies to remaining items after prefixItems + // For draft-07: additionalItems applies to remaining items after items array if schema.Items != nil { for i := len(schema.PrefixItems); i < instance.Len(); i++ { if err := st.validate(instance.Index(i), schema.Items, nil); err != nil { @@ -318,6 +341,14 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an } // Note that all the items in this array have been validated. anns.allItems = true + } else if schema.AdditionalItems != nil { + // Draft-07 style: use additionalItems for remaining items + for i := len(schema.PrefixItems); i < instance.Len(); i++ { + if err := st.validate(instance.Index(i), schema.AdditionalItems, nil); err != nil { + return err + } + } + anns.allItems = true } nContains := 0 @@ -522,6 +553,43 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an } } } + + // Draft-07 dependencies (combines both property and schema dependencies) + if schema.Dependencies != nil { + for dprop, dep := range schema.Dependencies { + if hasProperty(dprop) { + switch v := dep.(type) { + case []interface{}: + // Array of strings - property dependencies + var reqs []string + for _, item := range v { + if str, ok := item.(string); ok { + reqs = append(reqs, str) + } + } + if m := missingProperties(reqs); len(m) > 0 { + return fmt.Errorf("dependencies[%q]: missing properties %q", dprop, m) + } + case map[string]interface{}: + // Schema object - schema dependencies + // Convert map to Schema and resolve it properly + if data, err := json.Marshal(v); err == nil { + var depSchema Schema + if err := json.Unmarshal(data, &depSchema); err == nil { + // Resolve the dependency schema + resolved, err := depSchema.Resolve(nil) + if err != nil { + return fmt.Errorf("dependencies[%q]: failed to resolve schema: %w", dprop, err) + } + if err := resolved.Validate(instance.Interface()); err != nil { + return fmt.Errorf("dependencies[%q]: %w", dprop, err) + } + } + } + } + } + } + } if schema.UnevaluatedProperties != nil && !anns.allProperties { // This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf // in addition to sibling keywords.