internal/mcp/jsonschema: clearer validations errors

Validation errors now more clearly indicate the path from the root
schema.

- Enhance schema iteration to yield the path, and generalize it by using
  reflection.

- Store each schema's path inside the schema.

- Remove the path arg from validate: we don't need it anymore.

- Improve the test for the iterators and add one for validation
  errors.

Change-Id: Ief55133ef4ae2eac0736208a3b9f78ed69933b31
Reviewed-on: https://go-review.googlesource.com/c/tools/+/676375
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-26 07:49:23 -04:00 • committed by Gopher Robot
commit e3938ce68c

View file

@ -11,9 +11,13 @@ import (
"errors"
"fmt"
"iter"
"maps"
"math"
"net/url"
"reflect"
"regexp"
"slices"
"strconv"
)
// A Schema is a JSON schema object.
@ -139,6 +143,10 @@ type Schema struct {
// 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
@ -180,7 +188,9 @@ func (s *Schema) String() string {
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
return fmt.Sprintf("%q, anchor %s", s.base.uri.String(), a)
}
// TODO: return something better, like a JSON Pointer from the base.
if s.path != "" {
return s.path
}
return "<anonymous schema>"
}
@ -192,15 +202,6 @@ func (s *Schema) ResolvedRef() *Schema {
return s.resolvedRef
}
// 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")
@ -358,43 +359,57 @@ func (ip *integer) UnmarshalJSON(data []byte) error {
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 {
func (s *Schema) every(f func(*Schema, []string) bool, path []string) bool {
return s == nil ||
f(s) && s.everyChild(func(s *Schema) bool { return s.every(f) })
f(s, path) && s.everyChild(func(s *Schema, p []string) bool { return s.every(f, p) }, path)
}
// everyChild reports whether f is true for every immediate child schema of s.
// The second argument to f is the path to the schema appended to the argument path.
//
// It does not call f on nil-valued fields holding individual schemas, like Contains,
// because a nil value indicates that the field is absent.
// It does call f on 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
}
func (s *Schema) everyChild(f func(*Schema, []string) bool, path []string) bool {
if s == nil {
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) {
var (
schemaType = reflect.TypeFor[*Schema]()
schemaSliceType = reflect.TypeFor[[]*Schema]()
schemaMapType = reflect.TypeFor[map[string]*Schema]()
)
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, append(path, info.jsonName)) {
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
case schemaSliceType:
slice := fv.Interface().([]*Schema)
// A field that contains a slice of schemas. Yield nils so we can check for their presence.
for i, c := range slice {
if !f(c, append(path, info.jsonName, strconv.Itoa(i))) {
return false
}
}
case schemaMapType:
// A field that is a map of schemas. Ditto about nils.
// Sort keys for determinism.
m := fv.Interface().(map[string]*Schema)
for _, k := range slices.Sorted(maps.Keys(m)) {
if !f(m[k], append(path, info.jsonName, escapeJSONPointerSegment(k))) {
return false
}
}
}
}
@ -402,11 +417,38 @@ func (s *Schema) everyChild(f func(*Schema) bool) bool {
}
// all wraps every in an iterator.
func (s *Schema) all() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.every(yield) }
func (s *Schema) all() iter.Seq2[*Schema, []string] {
return func(yield func(*Schema, []string) bool) { s.every(yield, nil) }
}
// children wraps everyChild in an iterator.
func (s *Schema) children() iter.Seq[*Schema] {
return func(yield func(*Schema) bool) { s.everyChild(yield) }
func (s *Schema) children() iter.Seq2[*Schema, []string] {
var pathBuffer [4]string
return func(yield func(*Schema, []string) bool) { s.everyChild(yield, pathBuffer[:0]) }
}
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
}
}