From 9e88f4ff6898fda96388b88bf2c747b2ef920006 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Mon, 26 May 2025 13:31:17 -0400 Subject: [PATCH] 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 LUCI-TryBot-Result: Go LUCI --- jsonschema/resolve.go | 71 +++++++++++++++++++++++++------------ jsonschema/resolve_test.go | 4 +-- jsonschema/schema.go | 4 ++- jsonschema/validate.go | 25 +++++++++++++ jsonschema/validate_test.go | 34 ++++++++++++++++-- 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/jsonschema/resolve.go b/jsonschema/resolve.go index 30083f9..80090a2 100644 --- a/jsonschema/resolve.go +++ b/jsonschema/resolve.go @@ -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) } diff --git a/jsonschema/resolve_test.go b/jsonschema/resolve_test.go index 6bb3683..1b176bf 100644 --- a/jsonschema/resolve_test.go +++ b/jsonschema/resolve_test.go @@ -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) } diff --git a/jsonschema/schema.go b/jsonschema/schema.go index 21ad9a4..e96af52 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -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) diff --git a/jsonschema/validate.go b/jsonschema/validate.go index 28fbfd2..9ef830b 100644 --- a/jsonschema/validate.go +++ b/jsonschema/validate.go @@ -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 diff --git a/jsonschema/validate_test.go b/jsonschema/validate_test.go index d8dd4f7..7a78e5c 100644 --- a/jsonschema/validate_test.go +++ b/jsonschema/validate_test.go @@ -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) }