internal/mcp/jsonschema: validate defaults

Optionally validate a schema's defaults when resolving it.

We simply walk the tree of schemas, validating each locally. This can't
handle dynamic references, which require maintaing the stack from the
root. It seems unlikely that a default would require one, however.

Since there may be other options to Resolve, use an options struct
to future-proof it.

Also, fix a bug: you can't resolve a schema twice.

Change-Id: I374c582d9cc49d66d4ed56233d1070b8b3263bdf
Reviewed-on: https://go-review.googlesource.com/c/tools/+/676395
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-26 13:31:17 -04:00
commit 9e88f4ff68
5 changed files with 110 additions and 28 deletions

View file

@ -30,17 +30,33 @@ type Resolved struct {
// A Loader reads and unmarshals the schema at uri, if any. // A Loader reads and unmarshals the schema at uri, if any.
type Loader func(uri *url.URL) (*Schema, error) type Loader func(uri *url.URL) (*Schema, error)
// ResolveOptions are options for [Schema.Resolve].
type ResolveOptions struct {
// BaseURI is the URI relative to which the root schema should be resolved.
// If non-empty, must be an absolute URI (one that starts with a scheme).
// It is resolved (in the URI sense; see [url.ResolveReference]) with root's
// $id property.
// If the resulting URI is not absolute, then the schema cannot contain
// relative URI references.
BaseURI string
// Loader loads schemas that are referred to by a $ref but are not under the
// root schema (remote references).
// If nil, resolving a remote reference will return an error.
Loader Loader
// ValidateDefaults determines whether to validate values of "default" keywords
// against their schemas.
// The [JSON Schema specification] does not require this, but it is
// recommended if defaults will be used.
//
// [JSON Schema specification]: https://json-schema.org/understanding-json-schema/reference/annotations
ValidateDefaults bool
}
// Resolve resolves all references within the schema and performs other tasks that // Resolve resolves all references within the schema and performs other tasks that
// prepare the schema for validation. // prepare the schema for validation.
// // If opts is nil, the default values are used.
// baseURI can be empty, or an absolute URI (one that starts with a scheme). func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) {
// It is resolved (in the URI sense; see [url.ResolveReference]) with root's $id property. // There are up to five steps required to prepare a schema to validate.
// If the resulting URI is not absolute, then the schema cannot not contain relative URI references.
//
// loader loads schemas that are referred to by a $ref but not under root (a remote reference).
// If nil, remote references will return an error.
func (root *Schema) Resolve(baseURI string, loader Loader) (*Resolved, error) {
// There are four steps involved in preparing a schema to validate.
// 1. Load: read the schema from somewhere and unmarshal it. // 1. Load: read the schema from somewhere and unmarshal it.
// This schema (root) may have been loaded or created in memory, but other schemas that // This schema (root) may have been loaded or created in memory, but other schemas that
// come into the picture in step 4 will be loaded by the given loader. // come into the picture in step 4 will be loaded by the given loader.
@ -49,37 +65,48 @@ func (root *Schema) Resolve(baseURI string, loader Loader) (*Resolved, error) {
// 3. Resolve URIs: determine the base URI of the root and all its subschemas, and // 3. Resolve URIs: determine the base URI of the root and all its subschemas, and
// resolve (in the URI sense) all identifiers and anchors with their bases. This step results // resolve (in the URI sense) all identifiers and anchors with their bases. This step results
// in a map from URIs to schemas within root. // in a map from URIs to schemas within root.
// These three steps are idempotent. They may occur a several times on a schema, if
// it is loaded from several places.
// 4. Resolve references: all refs in the schemas are replaced with the schema they refer to. // 4. Resolve references: all refs in the schemas are replaced with the schema they refer to.
// 5. (Optional.) If opts.ValidateDefaults is true, validate the defaults.
if root.path != "" {
return nil, fmt.Errorf("jsonschema: Resolve: %s already resolved", root)
}
r := &resolver{loaded: map[string]*Resolved{}}
if opts != nil {
r.opts = *opts
}
var base *url.URL var base *url.URL
if baseURI == "" { if r.opts.BaseURI == "" {
base = &url.URL{} // so we can call ResolveReference on it base = &url.URL{} // so we can call ResolveReference on it
} else { } else {
var err error var err error
base, err = url.Parse(baseURI) base, err = url.Parse(r.opts.BaseURI)
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing base URI: %w", err) return nil, fmt.Errorf("parsing base URI: %w", err)
} }
} }
if loader == nil { if r.opts.Loader == nil {
loader = func(uri *url.URL) (*Schema, error) { r.opts.Loader = func(uri *url.URL) (*Schema, error) {
return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve") return nil, errors.New("cannot resolve remote schemas: no loader passed to Schema.Resolve")
} }
} }
r := &resolver{
loader: loader,
loaded: map[string]*Resolved{},
}
return r.resolve(root, base) resolved, err := r.resolve(root, base)
if err != nil {
return nil, err
}
if r.opts.ValidateDefaults {
if err := resolved.validateDefaults(); err != nil {
return nil, err
}
}
// TODO: before we return, throw away anything we don't need for validation. // TODO: before we return, throw away anything we don't need for validation.
return resolved, nil
} }
// A resolver holds the state for resolution. // A resolver holds the state for resolution.
type resolver struct { type resolver struct {
loader Loader opts ResolveOptions
// A cache of loaded and partly resolved schemas. (They may not have had their // A cache of loaded and partly resolved schemas. (They may not have had their
// refs resolved.) The cache ensures that the loader will never be called more // refs resolved.) The cache ensures that the loader will never be called more
// than once with the same URI, and that reference cycles are handled properly. // than once with the same URI, and that reference cycles are handled properly.
@ -406,7 +433,7 @@ func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, d
referencedSchema = lrs.root referencedSchema = lrs.root
} else { } else {
// Try to load the schema. // Try to load the schema.
ls, err := r.loader(fraglessRefURI) ls, err := r.opts.Loader(fraglessRefURI)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err) return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err)
} }

View file

@ -52,7 +52,7 @@ func TestCheckLocal(t *testing.T) {
"regexp", "regexp",
}, },
} { } {
_, err := tt.s.Resolve("", nil) _, err := tt.s.Resolve(nil)
if err == nil { if err == nil {
t.Errorf("%s: unexpectedly passed", tt.s.json()) t.Errorf("%s: unexpectedly passed", tt.s.json())
continue continue
@ -192,7 +192,7 @@ func TestRefCycle(t *testing.T) {
return s, nil return s, nil
} }
rs, err := schemas["root"].Resolve("", loader) rs, err := schemas["root"].Resolve(&ResolveOptions{Loader: loader})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -182,7 +182,9 @@ type anchorInfo struct {
// 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.uri != nil { if s.uri != nil {
return s.uri.String() if u := s.uri.String(); u != "" {
return u
}
} }
if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" { if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" {
return fmt.Sprintf("%q, anchor %s", s.base.uri.String(), a) return fmt.Sprintf("%q, anchor %s", s.base.uri.String(), a)

View file

@ -30,6 +30,31 @@ func (rs *Resolved) Validate(instance any) error {
return st.validate(reflect.ValueOf(instance), st.rs.root, nil) return st.validate(reflect.ValueOf(instance), st.rs.root, nil)
} }
// validateDefaults walks the schema tree. If it finds a default, it validates it
// against the schema containing it.
//
// TODO(jba): account for dynamic refs. This algorithm simple-mindedly
// treats each schema with a default as its own root.
func (rs *Resolved) validateDefaults() error {
if s := rs.root.Schema; s != "" && s != draft202012 {
return fmt.Errorf("cannot validate version %s, only %s", s, draft202012)
}
st := &state{rs: rs}
for s := range rs.root.all() {
// We checked for nil schemas in [Schema.Resolve].
assert(s != nil, "nil schema")
if s.DynamicRef != "" {
return fmt.Errorf("jsonschema: %s: validateDefaults does not support dynamic refs", s)
}
if s.Default != nil {
if err := st.validate(reflect.ValueOf(*s.Default), s, nil); err != nil {
return err
}
}
}
return nil
}
// state is the state of single call to ResolvedSchema.Validate. // state is the state of single call to ResolvedSchema.Validate.
type state struct { type state struct {
rs *Resolved rs *Resolved

View file

@ -53,7 +53,7 @@ func TestValidate(t *testing.T) {
} }
for _, g := range groups { for _, g := range groups {
t.Run(g.Description, func(t *testing.T) { t.Run(g.Description, func(t *testing.T) {
rs, err := g.Schema.Resolve("", loadRemote) rs, err := g.Schema.Resolve(&ResolveOptions{Loader: loadRemote})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -82,7 +82,7 @@ func TestValidateErrors(t *testing.T) {
schema := &Schema{ schema := &Schema{
PrefixItems: []*Schema{{Contains: &Schema{Type: "integer"}}}, PrefixItems: []*Schema{{Contains: &Schema{Type: "integer"}}},
} }
rs, err := schema.Resolve("", nil) rs, err := schema.Resolve(nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -93,6 +93,34 @@ func TestValidateErrors(t *testing.T) {
} }
} }
func TestValidateDefaults(t *testing.T) {
anyptr := func(x any) *any { return &x }
s := &Schema{
Properties: map[string]*Schema{
"a": {Type: "integer", Default: anyptr(3)},
"b": {Type: "string", Default: anyptr("s")},
},
Default: anyptr(map[string]any{"a": 1, "b": "two"}),
}
if _, err := s.Resolve(&ResolveOptions{ValidateDefaults: true}); err != nil {
t.Fatal(err)
}
s = &Schema{
Properties: map[string]*Schema{
"a": {Type: "integer", Default: anyptr(3)},
"b": {Type: "string", Default: anyptr("s")},
},
Default: anyptr(map[string]any{"a": 1, "b": 2}),
}
_, err := s.Resolve(&ResolveOptions{ValidateDefaults: true})
want := `has type "integer", want "string"`
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("Resolve returned error %q, want %q", err, want)
}
}
func TestStructInstance(t *testing.T) { func TestStructInstance(t *testing.T) {
instance := struct { instance := struct {
I int I int
@ -174,7 +202,7 @@ func TestStructInstance(t *testing.T) {
false, false,
}, },
} { } {
res, err := tt.s.Resolve("", nil) res, err := tt.s.Resolve(nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }