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.
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
// prepare the schema for validation.
//
// baseURI can be empty, or 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 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.
// If opts is nil, the default values are used.
func (root *Schema) Resolve(opts *ResolveOptions) (*Resolved, error) {
// There are up to five steps required to prepare a schema to validate.
// 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
// 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
// resolve (in the URI sense) all identifiers and anchors with their bases. This step results
// 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.
// 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
if baseURI == "" {
if r.opts.BaseURI == "" {
base = &url.URL{} // so we can call ResolveReference on it
} else {
var err error
base, err = url.Parse(baseURI)
base, err = url.Parse(r.opts.BaseURI)
if err != nil {
return nil, fmt.Errorf("parsing base URI: %w", err)
}
}
if loader == nil {
loader = func(uri *url.URL) (*Schema, error) {
if r.opts.Loader == nil {
r.opts.Loader = func(uri *url.URL) (*Schema, error) {
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.
return resolved, nil
}
// A resolver holds the state for resolution.
type resolver struct {
loader Loader
opts ResolveOptions
// 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
// 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
} else {
// Try to load the schema.
ls, err := r.loader(fraglessRefURI)
ls, err := r.opts.Loader(fraglessRefURI)
if err != nil {
return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err)
}

View file

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

View file

@ -182,7 +182,9 @@ type anchorInfo struct {
// String returns a short description of the schema.
func (s *Schema) String() string {
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 != "" {
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)
}
// 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.
type state struct {
rs *Resolved

View file

@ -53,7 +53,7 @@ func TestValidate(t *testing.T) {
}
for _, g := range groups {
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 {
t.Fatal(err)
}
@ -82,7 +82,7 @@ func TestValidateErrors(t *testing.T) {
schema := &Schema{
PrefixItems: []*Schema{{Contains: &Schema{Type: "integer"}}},
}
rs, err := schema.Resolve("", nil)
rs, err := schema.Resolve(nil)
if err != nil {
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) {
instance := struct {
I int
@ -174,7 +202,7 @@ func TestStructInstance(t *testing.T) {
false,
},
} {
res, err := tt.s.Resolve("", nil)
res, err := tt.s.Resolve(nil)
if err != nil {
t.Fatal(err)
}