go-sdk/jsonschema/schema.go
Jonathan Amsterdam 9e88f4ff68 internal/mcp/jsonschema: validate defaults
Optionally validate a schema's defaults when resolving it.

We simply walk the tree of schemas, validating each locally. This can't
handle dynamic references, which require maintaing the stack from the
root. It seems unlikely that a default would require one, however.

Since there may be other options to Resolve, use an options struct
to future-proof it.

Also, fix a bug: you can't resolve a schema twice.

Change-Id: I374c582d9cc49d66d4ed56233d1070b8b3263bdf
Reviewed-on: https://go-review.googlesource.com/c/tools/+/676395
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2025-05-28 07:50:15 -07:00

443 lines
14 KiB
Go

// 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 (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
"math"
"net/url"
"reflect"
"regexp"
"slices"
)
// 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
//
// A Schema value may have non-zero values for more than one field:
// all relevant non-zero fields are used for validation.
// There is one exception to provide more Go type-safety: the Type and Types fields
// are mutually exclusive.
//
// Since this struct is a Go representation of a JSON value, it inherits JSON's
// distinction between nil and empty. Nil slices and maps are considered absent,
// but empty ones are present and affect validation. For example,
//
// Schema{Enum: nil}
//
// is equivalent to an empty schema, so it validates every instance. But
//
// Schema{Enum: []any{}}
//
// requires equality to some slice element, so it vacuously rejects every instance.
type Schema struct {
// core
ID string `json:"$id,omitempty"`
Schema string `json:"$schema,omitempty"`
Ref string `json:"$ref,omitempty"`
Comment string `json:"$comment,omitempty"`
Defs map[string]*Schema `json:"$defs,omitempty"`
// definitions is deprecated but still allowed. It is a synonym for $defs.
Definitions map[string]*Schema `json:"definitions,omitempty"`
Anchor string `json:"$anchor,omitempty"`
DynamicAnchor string `json:"$dynamicAnchor,omitempty"`
DynamicRef string `json:"$dynamicRef,omitempty"`
Vocabulary map[string]bool `json:"$vocabulary,omitempty"`
// metadata
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default *any `json:"default,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
WriteOnly bool `json:"writeOnly,omitempty"`
Examples []any `json:"examples,omitempty"`
// validation
// Use Type for a single type, or Types for multiple types; never both.
Type string `json:"-"`
Types []string `json:"-"`
Enum []any `json:"enum,omitempty"`
// Const is *any because a JSON null (Go nil) is a valid value.
Const *any `json:"const,omitempty"`
MultipleOf *float64 `json:"multipleOf,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty"`
ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
Pattern string `json:"pattern,omitempty"`
// arrays
PrefixItems []*Schema `json:"prefixItems,omitempty"`
Items *Schema `json:"items,omitempty"`
MinItems *int `json:"minItems,omitempty"`
MaxItems *int `json:"maxItems,omitempty"`
AdditionalItems *Schema `json:"additionalItems,omitempty"`
UniqueItems bool `json:"uniqueItems,omitempty"`
Contains *Schema `json:"contains,omitempty"`
MinContains *int `json:"minContains,omitempty"` // *int, not int: default is 1, not 0
MaxContains *int `json:"maxContains,omitempty"`
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
// objects
MinProperties *int `json:"minProperties,omitempty"`
MaxProperties *int `json:"maxProperties,omitempty"`
Required []string `json:"required,omitempty"`
DependentRequired map[string][]string `json:"dependentRequired,omitempty"`
Properties map[string]*Schema `json:"properties,omitempty"`
PatternProperties map[string]*Schema `json:"patternProperties,omitempty"`
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
PropertyNames *Schema `json:"propertyNames,omitempty"`
UnevaluatedProperties *Schema `json:"unevaluatedProperties,omitempty"`
// logic
AllOf []*Schema `json:"allOf,omitempty"`
AnyOf []*Schema `json:"anyOf,omitempty"`
OneOf []*Schema `json:"oneOf,omitempty"`
Not *Schema `json:"not,omitempty"`
// conditional
If *Schema `json:"if,omitempty"`
Then *Schema `json:"then,omitempty"`
Else *Schema `json:"else,omitempty"`
DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"`
// other
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.8
ContentEncoding string `json:"contentEncoding,omitempty"`
ContentMediaType string `json:"contentMediaType,omitempty"`
ContentSchema *Schema `json:"contentSchema,omitempty"`
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7
Format string `json:"format,omitempty"`
// computed fields
// This schema's base schema.
// If the schema is the root or has an ID, its base is itself.
// Otherwise, its base is the innermost enclosing schema whose base
// is itself.
// Intuitively, a base schema is one that can be referred to with a
// fragmentless URI.
base *Schema
// The URI for the schema, if it is the root or has an ID.
// Otherwise nil.
// Invariants:
// s.base.uri != nil.
// s.base == s <=> s.uri != nil
uri *url.URL
// The JSON Pointer path from the root schema to here.
// Used in errors.
path string
// The schema to which Ref refers.
resolvedRef *Schema
// If the schema has a dynamic ref, exactly one of the next two fields
// will be non-zero after successful resolution.
// The schema to which the dynamic ref refers when it acts lexically.
resolvedDynamicRef *Schema
// The anchor to look up on the stack when the dynamic ref acts dynamically.
dynamicRefAnchor string
// Map from anchors to subschemas.
anchors map[string]anchorInfo
// compiled regexps
pattern *regexp.Regexp
patternProperties map[*regexp.Regexp]*Schema
// the set of required properties
isRequired map[string]bool
}
// falseSchema returns a new Schema tree that fails to validate any value.
func falseSchema() *Schema {
return &Schema{Not: &Schema{}}
}
// anchorInfo records the subschema to which an anchor refers, and whether
// the anchor keyword is $anchor or $dynamicAnchor.
type anchorInfo struct {
schema *Schema
dynamic bool
}
// String returns a short description of the schema.
func (s *Schema) String() string {
if s.uri != nil {
if u := s.uri.String(); u != "" {
return u
}
}
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
return fmt.Sprintf("%q, anchor %s", s.base.uri.String(), a)
}
if s.path != "" {
return s.path
}
return "<anonymous schema>"
}
// ResolvedRef returns the Schema to which this schema's $ref keyword
// refers, or nil if it doesn't have a $ref.
// It returns nil if this schema has not been resolved, meaning that
// [Schema.Resolve] was called on it or one of its ancestors.
func (s *Schema) ResolvedRef() *Schema {
return s.resolvedRef
}
func (s *Schema) basicChecks() error {
if s.Type != "" && s.Types != nil {
return errors.New("both Type and Types are set; at most one should be")
}
if s.Defs != nil && s.Definitions != nil {
return errors.New("both Defs and Definitions are set; at most one should be")
}
return nil
}
type schemaWithoutMethods Schema // doesn't implement json.{Unm,M}arshaler
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 {
case s.Type != "":
typ = s.Type
case s.Types != nil:
typ = s.Types
}
ms := struct {
Type any `json:"type,omitempty"`
*schemaWithoutMethods
}{
Type: typ,
schemaWithoutMethods: (*schemaWithoutMethods)(s),
}
return json.Marshal(ms)
}
func (s *Schema) UnmarshalJSON(data []byte) error {
// A JSON boolean is a valid schema.
var b bool
if err := json.Unmarshal(data, &b); err == nil {
if b {
// true is the empty schema, which validates everything.
*s = Schema{}
} else {
// false is the schema that validates nothing.
*s = *falseSchema()
}
return nil
}
ms := struct {
Type json.RawMessage `json:"type,omitempty"`
Const json.RawMessage `json:"const,omitempty"`
Default json.RawMessage `json:"default,omitempty"`
MinLength *integer `json:"minLength,omitempty"`
MaxLength *integer `json:"maxLength,omitempty"`
MinItems *integer `json:"minItems,omitempty"`
MaxItems *integer `json:"maxItems,omitempty"`
MinProperties *integer `json:"minProperties,omitempty"`
MaxProperties *integer `json:"maxProperties,omitempty"`
MinContains *integer `json:"minContains,omitempty"`
MaxContains *integer `json:"maxContains,omitempty"`
*schemaWithoutMethods
}{
schemaWithoutMethods: (*schemaWithoutMethods)(s),
}
if err := json.Unmarshal(data, &ms); err != nil {
return err
}
// Unmarshal "type" as either Type or Types.
var err error
if len(ms.Type) > 0 {
switch ms.Type[0] {
case '"':
err = json.Unmarshal(ms.Type, &s.Type)
case '[':
err = json.Unmarshal(ms.Type, &s.Types)
default:
err = fmt.Errorf(`invalid value for "type": %q`, ms.Type)
}
}
if err != nil {
return err
}
unmarshalAnyPtr := func(p **any, raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
if bytes.Equal(raw, []byte("null")) {
*p = new(any)
return nil
}
return json.Unmarshal(raw, p)
}
// Setting Const or Default to a pointer to null will marshal properly, but won't
// unmarshal: the *any is set to nil, not a pointer to nil.
if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
return err
}
if err := unmarshalAnyPtr(&s.Default, ms.Default); err != nil {
return err
}
set := func(dst **int, src *integer) {
if src != nil {
*dst = Ptr(int(*src))
}
}
set(&s.MinLength, ms.MinLength)
set(&s.MaxLength, ms.MaxLength)
set(&s.MinItems, ms.MinItems)
set(&s.MaxItems, ms.MaxItems)
set(&s.MinProperties, ms.MinProperties)
set(&s.MaxProperties, ms.MaxProperties)
set(&s.MinContains, ms.MinContains)
set(&s.MaxContains, ms.MaxContains)
return nil
}
type integer int32 // for the integer-valued fields of Schema
func (ip *integer) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
// nothing to do
return nil
}
// If there is a decimal point, src is a floating-point number.
var i int64
if bytes.ContainsRune(data, '.') {
var f float64
if err := json.Unmarshal(data, &f); err != nil {
return errors.New("not a number")
}
i = int64(f)
if float64(i) != f {
return errors.New("not an integer value")
}
} else {
if err := json.Unmarshal(data, &i); err != nil {
return errors.New("cannot be unmarshaled into an int")
}
}
// Ensure behavior is the same on both 32-bit and 64-bit systems.
if i < math.MinInt32 || i > math.MaxInt32 {
return errors.New("integer is out of range")
}
*ip = integer(i)
return nil
}
// Ptr returns a pointer to a new variable whose value is x.
func Ptr[T any](x T) *T { return &x }
// every applies f preorder to every schema under s including s.
// The second argument to f is the path to the schema appended to the argument path.
// It stops when f returns false.
func (s *Schema) every(f func(*Schema) bool) bool {
return f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) })
}
// everyChild reports whether f is true for every immediate child schema of s.
func (s *Schema) everyChild(f func(*Schema) bool) bool {
v := reflect.ValueOf(s)
for _, info := range schemaFieldInfos {
fv := v.Elem().FieldByIndex(info.sf.Index)
switch info.sf.Type {
case schemaType:
// A field that contains an individual schema. A nil is valid: it just means the field isn't present.
c := fv.Interface().(*Schema)
if c != nil && !f(c) {
return false
}
case schemaSliceType:
slice := fv.Interface().([]*Schema)
for _, c := range slice {
if !f(c) {
return false
}
}
case schemaMapType:
// Sort keys for determinism.
m := fv.Interface().(map[string]*Schema)
for _, k := range slices.Sorted(maps.Keys(m)) {
if !f(m[k]) {
return false
}
}
}
}
return true
}
// all wraps every in an iterator.
func (s *Schema) all() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.every(yield) }
}
// children wraps everyChild in an iterator.
func (s *Schema) children() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.everyChild(yield) }
}
var (
schemaType = reflect.TypeFor[*Schema]()
schemaSliceType = reflect.TypeFor[[]*Schema]()
schemaMapType = reflect.TypeFor[map[string]*Schema]()
)
type structFieldInfo struct {
sf reflect.StructField
jsonName string
}
var (
// the visible fields of Schema that have a JSON name, sorted by that name
schemaFieldInfos []structFieldInfo
// map from JSON name to field
schemaFieldMap = map[string]reflect.StructField{}
)
func init() {
for _, sf := range reflect.VisibleFields(reflect.TypeFor[Schema]()) {
if name, ok := jsonName(sf); ok {
schemaFieldInfos = append(schemaFieldInfos, structFieldInfo{sf, name})
}
}
slices.SortFunc(schemaFieldInfos, func(i1, i2 structFieldInfo) int {
return cmp.Compare(i1.jsonName, i2.jsonName)
})
for _, info := range schemaFieldInfos {
schemaFieldMap[info.jsonName] = info.sf
}
}