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:
parent
f8098af1c5
commit
435f5447df
4 changed files with 153 additions and 19 deletions
|
|
@ -102,7 +102,7 @@ func typeSchema(t reflect.Type, seen map[reflect.Type]*Schema) (*Schema, error)
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
s.Type = "object"
|
s.Type = "object"
|
||||||
// no additional properties are allowed
|
// no additional properties are allowed
|
||||||
s.AdditionalProperties = &Schema{Not: &Schema{}}
|
s.AdditionalProperties = falseSchema()
|
||||||
|
|
||||||
for i := range t.NumField() {
|
for i := range t.NumField() {
|
||||||
field := t.Field(i)
|
field := t.Field(i)
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,11 @@ type Schema struct {
|
||||||
patternProperties map[*regexp.Regexp]*Schema
|
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.
|
// String returns a short description of the schema.
|
||||||
func (s *Schema) String() string {
|
func (s *Schema) String() string {
|
||||||
if s.ID != "" {
|
if s.ID != "" {
|
||||||
|
|
@ -182,7 +187,7 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
|
||||||
*s = Schema{}
|
*s = Schema{}
|
||||||
} else {
|
} else {
|
||||||
// false is the schema that validates nothing.
|
// false is the schema that validates nothing.
|
||||||
*s = Schema{Not: &Schema{}}
|
*s = *falseSchema()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ package jsonschema
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/maphash"
|
"hash/maphash"
|
||||||
|
"iter"
|
||||||
"math"
|
"math"
|
||||||
"math/big"
|
"math/big"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"unicode/utf8"
|
"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].
|
// We checked for nil schemas in [Schema.Resolve].
|
||||||
assert(schema != nil, "nil schema")
|
assert(schema != nil, "nil schema")
|
||||||
|
|
||||||
// Step through interfaces.
|
// Step through interfaces and pointers.
|
||||||
if instance.IsValid() && instance.Kind() == reflect.Interface {
|
for instance.Kind() == reflect.Pointer || instance.Kind() == reflect.Interface {
|
||||||
instance = instance.Elem()
|
instance = instance.Elem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,16 +326,18 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
|
||||||
|
|
||||||
// objects
|
// objects
|
||||||
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
|
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2
|
||||||
if instance.Kind() == reflect.Map {
|
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
|
||||||
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
|
if instance.Kind() == reflect.Map {
|
||||||
return fmt.Errorf("map key type %s is not a string", kt)
|
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.
|
// 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
|
// If we used anns here, then we'd be including properties evaluated in subschemas
|
||||||
// from allOf, etc., which additionalProperties shouldn't observe.
|
// from allOf, etc., which additionalProperties shouldn't observe.
|
||||||
evalProps := map[string]bool{}
|
evalProps := map[string]bool{}
|
||||||
for prop, schema := range schema.Properties {
|
for prop, schema := range schema.Properties {
|
||||||
val := instance.MapIndex(reflect.ValueOf(prop))
|
val := property(instance, prop)
|
||||||
if !val.IsValid() {
|
if !val.IsValid() {
|
||||||
// It's OK if the instance doesn't have the property.
|
// It's OK if the instance doesn't have the property.
|
||||||
continue
|
continue
|
||||||
|
|
@ -344,8 +348,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
|
||||||
evalProps[prop] = true
|
evalProps[prop] = true
|
||||||
}
|
}
|
||||||
if len(schema.PatternProperties) > 0 {
|
if len(schema.PatternProperties) > 0 {
|
||||||
for vprop, val := range instance.Seq2() {
|
for prop, val := range properties(instance) {
|
||||||
prop := vprop.String()
|
|
||||||
// Check every matching pattern.
|
// Check every matching pattern.
|
||||||
for re, schema := range schema.patternProperties {
|
for re, schema := range schema.patternProperties {
|
||||||
if re.MatchString(prop) {
|
if re.MatchString(prop) {
|
||||||
|
|
@ -359,8 +362,7 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
|
||||||
}
|
}
|
||||||
if schema.AdditionalProperties != nil {
|
if schema.AdditionalProperties != nil {
|
||||||
// Apply to all properties not handled above.
|
// Apply to all properties not handled above.
|
||||||
for vprop, val := range instance.Seq2() {
|
for prop, val := range properties(instance) {
|
||||||
prop := vprop.String()
|
|
||||||
if !evalProps[prop] {
|
if !evalProps[prop] {
|
||||||
if err := st.validate(val, schema.AdditionalProperties, nil, append(path, prop)); err != nil {
|
if err := st.validate(val, schema.AdditionalProperties, nil, append(path, prop)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -371,8 +373,10 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
|
||||||
}
|
}
|
||||||
anns.noteProperties(evalProps)
|
anns.noteProperties(evalProps)
|
||||||
if schema.PropertyNames != nil {
|
if schema.PropertyNames != nil {
|
||||||
for prop := range instance.Seq() {
|
// Note: properties unnecessarily fetches each value. We could define a propertyNames function
|
||||||
if err := st.validate(prop, schema.PropertyNames, nil, append(path, prop.String())); err != nil {
|
// 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
|
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
|
// https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.5
|
||||||
if schema.MinProperties != nil {
|
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)
|
return fmt.Errorf("minProperties: object has %d properties, less than %d", n, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if schema.MaxProperties != nil {
|
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)
|
return fmt.Errorf("maxProperties: object has %d properties, greater than %d", n, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasProperty := func(prop string) bool {
|
hasProperty := func(prop string) bool {
|
||||||
return instance.MapIndex(reflect.ValueOf(prop)).IsValid()
|
return property(instance, prop).IsValid()
|
||||||
}
|
}
|
||||||
|
|
||||||
missingProperties := func(props []string) []string {
|
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 {
|
if schema.UnevaluatedProperties != nil && !anns.allProperties {
|
||||||
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
|
// This looks a lot like AdditionalProperties, but depends on in-place keywords like allOf
|
||||||
// in addition to sibling keywords.
|
// in addition to sibling keywords.
|
||||||
for vprop, val := range instance.Seq2() {
|
for prop, val := range properties(instance) {
|
||||||
prop := vprop.String()
|
|
||||||
if !anns.evaluatedProperties[prop] {
|
if !anns.evaluatedProperties[prop] {
|
||||||
if err := st.validate(val, schema.UnevaluatedProperties, nil, append(path, prop)); err != nil {
|
if err := st.validate(val, schema.UnevaluatedProperties, nil, append(path, prop)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -460,6 +463,100 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
|
||||||
return nil
|
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 {
|
func formatPath(path []any) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i, p := range path {
|
for i, p := range path {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue