go-sdk/internal/jsonschema/schema.go
Jonathan Amsterdam 9396e7f6a6 jsonschema: validate logic keywords
Validate keywords that combine schemas with various logical operators.

Also add iterators over schemas. Now we only need them to skip some
tests, but later they will come in handy for non-test code.

Change-Id: Idaf41efea4d693b6be0ee5c6c3a527664b8c472f
Reviewed-on: https://go-review.googlesource.com/c/tools/+/668115
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
2025-04-30 12:16:32 -07:00

263 lines
8.6 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 is an implementation of the JSON Schema
// specification: https://json-schema.org.
package jsonschema
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"iter"
)
// 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"`
// 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 *float64 `json:"minLength,omitempty"`
MaxLength *float64 `json:"maxLength,omitempty"`
Pattern string `json:"pattern,omitempty"`
// arrays
PrefixItems []*Schema `json:"prefixItems,omitempty"`
Items *Schema `json:"items,omitempty"`
MinItems *float64 `json:"minItems,omitempty"`
MaxItems *float64 `json:"maxItems,omitempty"`
AdditionalItems *Schema `json:"additionalItems,omitempty"`
UniqueItems bool `json:"uniqueItems,omitempty"`
Contains *Schema `json:"contains,omitempty"`
MinContains *float64 `json:"minContains,omitempty"`
MaxContains *float64 `json:"maxContains,omitempty"`
UnevaluatedItems *Schema `json:"unevaluatedItems,omitempty"`
// objects
MinProperties *float64 `json:"minProperties,omitempty"`
MaxProperties *float64 `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"`
}
// String returns a short description of the schema.
func (s *Schema) String() string {
if s.ID != "" {
return s.ID
}
// TODO: return something better, like a JSON Pointer from the base.
return "<anonymous schema>"
}
// json returns the schema in json format.
func (s *Schema) json() string {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Sprintf("<jsonschema.Schema:%v>", err)
}
return string(data)
}
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 = Schema{Not: &Schema{}}
}
return nil
}
ms := struct {
Type json.RawMessage `json:"type,omitempty"`
Const json.RawMessage `json:"const,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 type: %q", ms.Type)
}
}
if err != nil {
return err
}
// Setting Const to a pointer to null will marshal properly, but won't unmarshal:
// the *any is set to nil, not a pointer to nil.
if len(ms.Const) > 0 {
if bytes.Equal(ms.Const, []byte("null")) {
s.Const = new(any)
} else if err := json.Unmarshal(ms.Const, &s.Const); err != nil {
return err
}
}
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.
// It stops when f returns false.
func (s *Schema) every(f func(*Schema) bool) bool {
return s == nil ||
f(s) && s.everyChild(f)
}
// everyChild returns an iterator over the immediate child schemas of s.
//
// It does not yield nils from fields holding individual schemas, like Contains,
// because a nil value indicates that the field is absent.
// It does yield nils when they occur in slices and maps, so those invalid values
// can be detected when the schema is validated.
func (s *Schema) everyChild(f func(*Schema) bool) bool {
// Fields that contain individual schemas. A nil is valid: it just means the field isn't present.
for _, c := range []*Schema{
s.Items, s.AdditionalItems, s.Contains, s.PropertyNames, s.AdditionalProperties,
s.If, s.Then, s.Else, s.Not, s.UnevaluatedItems, s.UnevaluatedProperties,
} {
if c != nil && !f(c) {
return false
}
}
// Fields that contain slices of schemas. Yield nils so we can check for their presence.
for _, sl := range [][]*Schema{s.PrefixItems, s.AllOf, s.AnyOf, s.OneOf} {
for _, c := range sl {
if !f(c) {
return false
}
}
}
// Fields that are maps of schemas. Ditto about nils.
for _, m := range []map[string]*Schema{
s.Defs, s.Definitions, s.Properties, s.PatternProperties, s.DependentSchemas,
} {
for _, c := range m {
if !f(c) {
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) }
}