internal/mcp/jsonschema: support validating structs

The instance passed to Resolved.Validate can be a struct, or can
contain structs. A struct is treated like the JSON object that it
marshals to.

Also support pointers to any depth. That is, if an instance is a **int,
then Validate will treat it like an int (or nil). That won't happen if
the instance is unmarshaled into an `any`, but may if it's unmarshaled
into a struct.

Change-Id: I39f9af58028bd779887495754519615cd5dfb6c8
Reviewed-on: https://go-review.googlesource.com/c/tools/+/670395
Reviewed-by: Alan Donovan <adonovan@google.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-06 07:25:27 -04:00 • committed by Gopher Robot
commit 435f5447df
4 changed files with 153 additions and 19 deletions

View file

@ -102,7 +102,7 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
case reflect.Struct:
s.Type = "object"
// no additional properties are allowed
s.AdditionalProperties = &Schema{Not: &Schema{}}
s.AdditionalProperties = falseSchema()
for i := range t.NumField() {
field := t.Field(i)

View file

@ -121,6 +121,11 @@ type Schema struct {
patternProperties map[*regexp.Regexp]*Schema
}
// falseSchema returns a new Schema tree that fails to validate any value.
func falseSchema() *Schema {
return &Schema{Not: &Schema{}}
}
// String returns a short description of the schema.
func (s *Schema) String() string {
if s.ID != "" {
@ -182,7 +187,7 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
*s = Schema{}
} else {
// false is the schema that validates nothing.
*s = Schema{Not: &Schema{}}
*s = *falseSchema()
}
return nil
}

View file

@ -7,11 +7,13 @@ package jsonschema
import (
"fmt"
"hash/maphash"
"iter"
"math"
"math/big"
"reflect"
"slices"
"strings"
"sync"
"unicode/utf8"
)
@ -55,8 +57,8 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
// We checked for nil schemas in [Schema.Resolve].
assert(schema != nil, "nil schema")
// Step through interfaces.
if instance.IsValid() && instance.Kind() == reflect.Interface {
// Step through interfaces and pointers.
for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface {
instance = instance.Elem()
}
@ -324,16 +326,18 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
// objects
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
if instance.Kind() == reflect.Map {
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
return fmt.Errorf("map key type %s is not a string", kt)
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
if instance.Kind() == reflect.Map {
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
return fmt.Errorf("map key type %s is not a string", kt)
}
}
// Track the evaluated properties for just this schema, to support additionalProperties.
// If we used anns here, then we'd be including properties evaluated in subschemas
// from allOf, etc., which additionalProperties shouldn't observe.
evalProps := map[string]bool{}
for prop, schema := range schema.Properties {
val := instance.MapIndex(reflect.ValueOf(prop))
val := property(instance, prop)
if !val.IsValid() {
// It's OK if the instance doesn't have the property.
continue
@ -344,8 +348,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
evalProps[prop] = true
}
if len(schema.PatternProperties) > 0 {
for vprop, val := range instance.Seq2() {
prop := vprop.String()
for prop, val := range properties(instance) {
// Check every matching pattern.
for re, schema := range schema.patternProperties {
if re.MatchString(prop) {
@ -359,8 +362,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
}
if schema.AdditionalProperties != nil {
// Apply to all properties not handled above.
for vprop, val := range instance.Seq2() {
prop := vprop.String()
for prop, val := range properties(instance) {
if !evalProps[prop] {
if err := st.validate(val, schema.AdditionalProperties, nil, append(path, prop)); err != nil {
return err
@ -371,8 +373,10 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
}
anns.noteProperties(evalProps)
if schema.PropertyNames != nil {
for prop := range instance.Seq() {
if err := st.validate(prop, schema.PropertyNames, nil, append(path, prop.String())); err != nil {
// Note: properties unnecessarily fetches each value. We could define a propertyNames function
// if performance ever matters.
for prop := range properties(instance) {
if err := st.validate(reflect.ValueOf(prop), schema.PropertyNames, nil, append(path, prop)); err != nil {
return err
}
}
@ -380,18 +384,18 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
if schema.MinProperties != nil {
if n, m := instance.Len(), *schema.MinProperties; n < m {
if n, m := numProperties(instance), *schema.MinProperties; n < m {
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
}
}
if schema.MaxProperties != nil {
if n, m := instance.Len(), *schema.MaxProperties; n > m {
if n, m := numProperties(instance), *schema.MaxProperties; n > m {
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
}
}
hasProperty := func(prop string) bool {
return instance.MapIndex(reflect.ValueOf(prop)).IsValid()
return property(instance, prop).IsValid()
}
missingProperties := func(props []string) []string {
@ -438,8 +442,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
if schema.UnevaluatedProperties != nil && !anns.allProperties {
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
// in addition to sibling keywords.
for vprop, val := range instance.Seq2() {
prop := vprop.String()
for prop, val := range properties(instance) {
if !anns.evaluatedProperties[prop] {
if err := st.validate(val, schema.UnevaluatedProperties, nil, append(path, prop)); err != nil {
return err
@ -460,6 +463,100 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
return nil
}
// property returns the value of the property of v with the given name, or the invalid
// reflect.Value if there is none.
// If v is a map, the property is the value of the map whose key is name.
// If v is a struct, the property is the value of the field with the given name according
// to the encoding/json package (see [jsonName]).
// If v is anything else, property panics.
func property(v reflect.Value, name string) reflect.Value {
switch v.Kind() {
case reflect.Map:
return v.MapIndex(reflect.ValueOf(name))
case reflect.Struct:
props := structPropertiesOf(v.Type())
if index, ok := props[name]; ok {
return v.FieldByIndex(index)
}
return reflect.Value{}
default:
panic(fmt.Sprintf("property(%q): bad value %s of kind %s", name, v, v.Kind()))
}
}
// properties returns an iterator over the names and values of all properties
// in v, which must be a map or a struct.
func properties(v reflect.Value) iter.Seq2[string, reflect.Value] {
return func(yield func(string, reflect.Value) bool) {
switch v.Kind() {
case reflect.Map:
for k, e := range v.Seq2() {
if !yield(k.String(), e) {
return
}
}
case reflect.Struct:
for name, index := range structPropertiesOf(v.Type()) {
if !yield(name, v.FieldByIndex(index)) {
return
}
}
default:
panic(fmt.Sprintf("bad value %s of kind %s", v, v.Kind()))
}
}
}
// numProperties returns the number of v's properties.
// v must be a map or a struct.
func numProperties(v reflect.Value) int {
switch v.Kind() {
case reflect.Map:
return v.Len()
case reflect.Struct:
return len(structPropertiesOf(v.Type()))
default:
panic(fmt.Sprintf("properties: bad value: %s of kind %s", v, v.Kind()))
}
}
// A propertyMap is a map from property name to struct field index.
type propertyMap = map[string][]int
var structProperties sync.Map // from reflect.Type to propertyMap
// structPropertiesOf returns the JSON Schema properties for the struct type t.
// The caller must not mutate the result.
func structPropertiesOf(t reflect.Type) propertyMap {
// Mutex not necessary: at worst we'll recompute the same value.
if props, ok := structProperties.Load(t); ok {
return props.(propertyMap)
}
props := map[string][]int{}
for _, sf := range reflect.VisibleFields(t) {
if name, ok := jsonName(sf); ok {
props[name] = sf.Index
}
}
structProperties.Store(t, props)
return props
}
// jsonName returns the name for f as would be used by [json.Marshal].
// That is the name in the json struct tag, or the field name if there is no tag.
// If f is not exported or the tag name is "-", jsonName returns "", false.
func jsonName(f reflect.StructField) (string, bool) {
if !f.IsExported() {
return "", false
}
if tag, ok := f.Tag.Lookup("json"); ok {
if name, _, _ := strings.Cut(tag, ","); name != "" {
return name, name != "-"
}
}
return f.Name, true
}
func formatPath(path []any) string {
var b strings.Builder
for i, p := range path {

View file

@ -80,3 +80,35 @@ func TestValidate(t *testing.T) {
})
}
}
func TestStructInstance(t *testing.T) {
instance := struct {
I int
B bool `json:"b"`
u int
}{1, true, 0}
// The instance fails for all of these schemas, demonstrating that it
// was processed correctly.
for _, schema := range []*Schema{
{MinProperties: Ptr(3)},
{MaxProperties: Ptr(1)},
{Required: []string{"i"}}, // the name is "I"
{Required: []string{"B"}}, // the name is "b"
{PropertyNames: &Schema{MinLength: Ptr(2)}},
{Properties: map[string]*Schema{"b": {Type: "number"}}},
{Required: []string{"I"}, AdditionalProperties: falseSchema()},
{DependentRequired: map[string][]string{"b": {"u"}}},
{DependentSchemas: map[string]*Schema{"b": falseSchema()}},
{UnevaluatedProperties: falseSchema()},
} {
res, err := schema.Resolve("")
if err != nil {
t.Fatal(err)
}
err = res.Validate(instance)
if err == nil {
t.Errorf("succeeded but wanted failure; schema = %s", schema.json())
}
}
}