diff --git a/jsonschema/doc.go b/jsonschema/doc.go index 0f0ba441..553e2a9a 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/schema.go b/jsonschema/schema.go index 4b1d6eed..12667f25 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -20,10 +20,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. @@ -104,6 +104,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"` @@ -176,11 +179,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), } return marshalStructWithMap(&ms, "Extra") @@ -202,6 +218,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"` @@ -267,6 +284,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 } @@ -342,6 +387,7 @@ func (s *Schema) everyChild(f func(*Schema) bool) bool { } } } + return true } diff --git a/jsonschema/validate.go b/jsonschema/validate.go index 3b864107..533a5115 100644 --- a/jsonschema/validate.go +++ b/jsonschema/validate.go @@ -20,15 +20,34 @@ import ( "github.com/modelcontextprotocol/go-sdk/internal/util" ) -// 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) @@ -40,8 +59,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() { @@ -298,6 +317,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. @@ -312,6 +333,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 { @@ -320,6 +343,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 @@ -524,6 +555,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.