go-sdk/internal/jsonschema/validate.go
Jonathan Amsterdam 061f842459 jsonschema: validate numbers and strings
Add validation for keywords related to numbers and strings.

It may be surprising that keywords like "minimum" or "minLength"
don't imply that the instance is a number or string.
But that is how JSON Schema is designed.

Change-Id: I8805a5fe91ee345931e1c989f174ea2d0be6665a
Reviewed-on: https://go-review.googlesource.com/c/tools/+/667975
Reviewed-by: Robert Findley <rfindley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
2025-04-30 09:35:26 -07:00

185 lines
5.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
import (
"fmt"
"math"
"math/big"
"reflect"
"regexp"
"slices"
"strings"
"unicode/utf8"
)
// The value of the "$schema" keyword for the version that we can validate.
const draft202012 = "https://json-schema.org/draft/2020-12/schema"
// Temporary definition of ResolvedSchema.
// The full definition deals with references between schemas, specifically the $id, $anchor and $ref keywords.
// We'll ignore that for now.
type ResolvedSchema struct {
root *Schema
}
// 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.
func (rs *ResolvedSchema) Validate(instance any) error {
if s := rs.root.Schema; s != "" && s != draft202012 {
return fmt.Errorf("cannot validate version %s, only %s", s, draft202012)
}
st := &state{rs: rs}
return st.validate(reflect.ValueOf(instance), st.rs.root, nil)
}
// state is the state of single call to ResolvedSchema.Validate.
type state struct {
rs *ResolvedSchema
depth int
}
// validate validates the reflected value of the instance.
// It keeps track of the path within the instance for better error messages.
func (st *state) validate(instance reflect.Value, schema *Schema, path []any) (err error) {
defer func() {
if err != nil {
if p := formatPath(path); p != "" {
err = fmt.Errorf("%s: %w", p, err)
}
}
}()
st.depth++
defer func() { st.depth-- }()
if st.depth >= 100 {
return fmt.Errorf("max recursion depth of %d reached", st.depth)
}
// Treat the nil schema like the empty schema, as accepting everything.
if schema == nil {
return nil
}
// Step through interfaces.
if instance.IsValid() && instance.Kind() == reflect.Interface {
instance = instance.Elem()
}
// type: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.1
if schema.Type != "" || schema.Types != nil {
gotType, ok := jsonType(instance)
if !ok {
return fmt.Errorf("%v of type %[1]T is not a valid JSON value", instance)
}
if schema.Type != "" {
// "number" subsumes integers
if !(gotType == schema.Type ||
gotType == "integer" && schema.Type == "number") {
return fmt.Errorf("type: %s has type %q, want %q", instance, gotType, schema.Type)
}
} else {
if !(slices.Contains(schema.Types, gotType) || (gotType == "integer" && slices.Contains(schema.Types, "number"))) {
return fmt.Errorf("type: %s has type %q, want one of %q",
instance, gotType, strings.Join(schema.Types, ", "))
}
}
}
// enum: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.2
if schema.Enum != nil {
ok := false
for _, e := range schema.Enum {
if equalValue(reflect.ValueOf(e), instance) {
ok = true
break
}
}
if !ok {
return fmt.Errorf("enum: %v does not equal any of: %v", instance, schema.Enum)
}
}
// const: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.1.3
if schema.Const != nil {
if !equalValue(reflect.ValueOf(*schema.Const), instance) {
return fmt.Errorf("const: %v does not equal %v", instance, *schema.Const)
}
}
// numbers: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2
if schema.MultipleOf != nil || schema.Minimum != nil || schema.Maximum != nil || schema.ExclusiveMinimum != nil || schema.ExclusiveMaximum != nil {
n, ok := jsonNumber(instance)
if ok { // these keywords don't apply to non-numbers
if schema.MultipleOf != nil {
// TODO: validate MultipleOf as non-zero.
// The test suite assumes floats.
nf, _ := n.Float64() // don't care if it's exact or not
if _, f := math.Modf(nf / *schema.MultipleOf); f != 0 {
return fmt.Errorf("multipleOf: %s is not a multiple of %f", n, *schema.MultipleOf)
}
}
m := new(big.Rat) // reuse for all of the following
cmp := func(f float64) int { return n.Cmp(m.SetFloat64(f)) }
if schema.Minimum != nil && cmp(*schema.Minimum) < 0 {
return fmt.Errorf("minimum: %s is less than %f", n, *schema.Minimum)
}
if schema.Maximum != nil && cmp(*schema.Maximum) > 0 {
return fmt.Errorf("maximum: %s is greater than %f", n, *schema.Maximum)
}
if schema.ExclusiveMinimum != nil && cmp(*schema.ExclusiveMinimum) <= 0 {
return fmt.Errorf("exclusiveMinimum: %s is less than or equal to %f", n, *schema.ExclusiveMinimum)
}
if schema.ExclusiveMaximum != nil && cmp(*schema.ExclusiveMaximum) >= 0 {
return fmt.Errorf("exclusiveMaximum: %s is greater than or equal to %f", n, *schema.ExclusiveMaximum)
}
}
}
// strings: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3
if instance.Kind() == reflect.String && (schema.MinLength != nil || schema.MaxLength != nil || schema.Pattern != "") {
str := instance.String()
n := utf8.RuneCountInString(str)
if schema.MinLength != nil {
if m := int(*schema.MinLength); n < m {
return fmt.Errorf("minLength: %q contains %d Unicode code points, fewer than %d", str, n, m)
}
}
if schema.MaxLength != nil {
if m := int(*schema.MaxLength); n > m {
return fmt.Errorf("maxLength: %q contains %d Unicode code points, more than %d", str, n, m)
}
}
if schema.Pattern != "" {
// TODO(jba): compile regexps during schema validation.
m, err := regexp.MatchString(schema.Pattern, str)
if err != nil {
return err
}
if !m {
return fmt.Errorf("pattern: %q does not match pattern %q", str, schema.Pattern)
}
}
}
return nil
}
func formatPath(path []any) string {
var b strings.Builder
for i, p := range path {
if n, ok := p.(int); ok {
fmt.Fprintf(&b, "[%d]", n)
} else {
if i > 0 {
b.WriteByte('.')
}
fmt.Fprintf(&b, "%q", p)
}
}
return b.String()
}