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>
443 lines
14 KiB
Go
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
|
|
}
|
|
}
|