go-sdk/internal/jsonschema/schema.go
Jonathan Amsterdam 40185dd013 jsonschema: represent integer keywords as ints
Some JSON Schema keywords, like minLength, are logically integers.
The JSON Schema spec allows them to be written as floating-point
values with zero fractional part, which is a reasonable choice, since
JSON doesn't distinguish the two.

We could simply validate these fields as integers in the normal course
of events (namely, validating the schema against its meta-schema),
but this CL does a little better: it uses *int struct fields for
these keywords, preventing them from ever being non-integers.
The UnmarshalJSON method still accepts integral floating-point values.
Marshaling is unaffected, and the results don't change: encoding/json
has always marshaled integral floats without the decimal part.

Other choices:
- Leave them as float64: you could then get away with
    Schema{MinLength: Ptr(1.5)}
  and though you couldn't get far with that, it's still irksome.

- json.Number: same problem as float64, plus it's even harder to write
  as a literal.

- int64: now `Ptr(1)` doesn't work (it's a *int, not a *int64).
  Anyway, these keywords all compare against len(x) in Go, which
  is of type int.

Change-Id: Ie3f08bd8a5deaf2a21344d6ab873551c011b12d5
Reviewed-on: https://go-review.googlesource.com/c/tools/+/668315
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Robert Findley <rfindley@google.com>
2025-05-02 10:55:18 -07:00

302 lines
9.9 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"
"cmp"
"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 *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"`
}
// 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"`
MinLength *float64 `json:"minLength,omitempty"`
MaxLength *float64 `json:"maxLength,omitempty"`
MinItems *float64 `json:"minItems,omitempty"`
MaxItems *float64 `json:"maxItems,omitempty"`
MinProperties *float64 `json:"minProperties,omitempty"`
MaxProperties *float64 `json:"maxProperties,omitempty"`
MinContains *float64 `json:"minContains,omitempty"`
MaxContains *float64 `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
}
// 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
}
}
// Store integer properties as ints.
setInt := func(name string, dst **int, src *float64) error {
if src == nil {
return nil
}
i := int(*src)
if float64(i) != *src {
return fmt.Errorf("%s: %f is not an int", name, *src)
}
*dst = &i
return nil
}
err = cmp.Or(
setInt("minLength", &s.MinLength, ms.MinLength),
setInt("maxLength", &s.MaxLength, ms.MaxLength),
setInt("minItems", &s.MinItems, ms.MinItems),
setInt("maxItems", &s.MaxItems, ms.MaxItems),
setInt("minProperties", &s.MinProperties, ms.MinProperties),
setInt("maxProperties", &s.MaxProperties, ms.MaxProperties),
setInt("minContains", &s.MinContains, ms.MinContains),
setInt("maxContains", &s.MaxContains, ms.MaxContains),
)
if 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) }
}