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>
210 lines
5 KiB
Go
210 lines
5 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by an MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package jsonschema
|
|
|
|
import (
|
|
"errors"
|
|
"maps"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSchemaStructure(t *testing.T) {
|
|
check := func(s *Schema, want string) {
|
|
t.Helper()
|
|
err := s.checkStructure()
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("checkStructure returned error %q, want %q", err, want)
|
|
}
|
|
}
|
|
|
|
dag := &Schema{Type: "number"}
|
|
dag = &Schema{Items: dag, Contains: dag}
|
|
check(dag, "do not form a tree")
|
|
|
|
tree := &Schema{Type: "number"}
|
|
tree.Items = tree
|
|
check(tree, "do not form a tree")
|
|
|
|
sliceNil := &Schema{PrefixItems: []*Schema{nil}}
|
|
check(sliceNil, "is nil")
|
|
|
|
sliceMap := &Schema{Properties: map[string]*Schema{"a": nil}}
|
|
check(sliceMap, "is nil")
|
|
}
|
|
|
|
func TestCheckLocal(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
s *Schema
|
|
want string // error must be non-nil and match this regexp
|
|
}{
|
|
{
|
|
&Schema{Pattern: "]["},
|
|
"regexp",
|
|
},
|
|
{
|
|
&Schema{PatternProperties: map[string]*Schema{"*": {}}},
|
|
"regexp",
|
|
},
|
|
} {
|
|
_, err := tt.s.Resolve(nil)
|
|
if err == nil {
|
|
t.Errorf("%s: unexpectedly passed", tt.s.json())
|
|
continue
|
|
}
|
|
if !regexp.MustCompile(tt.want).MatchString(err.Error()) {
|
|
t.Errorf("checkLocal returned error\n%q\nwanted it to match\n%s\nregexp: %s",
|
|
tt.s.json(), err, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPaths(t *testing.T) {
|
|
// CheckStructure should assign paths to schemas.
|
|
// This test also verifies that Schema.all visits maps in sorted order.
|
|
root := &Schema{
|
|
Type: "string",
|
|
PrefixItems: []*Schema{{Type: "int"}, {Items: &Schema{Type: "null"}}},
|
|
Contains: &Schema{Properties: map[string]*Schema{
|
|
"~1": {Type: "boolean"},
|
|
"p": {},
|
|
}},
|
|
}
|
|
|
|
type item struct {
|
|
s *Schema
|
|
p string
|
|
}
|
|
want := []item{
|
|
{root, "root"},
|
|
{root.Contains, "/contains"},
|
|
{root.Contains.Properties["p"], "/contains/properties/p"},
|
|
{root.Contains.Properties["~1"], "/contains/properties/~01"},
|
|
{root.PrefixItems[0], "/prefixItems/0"},
|
|
{root.PrefixItems[1], "/prefixItems/1"},
|
|
{root.PrefixItems[1].Items, "/prefixItems/1/items"},
|
|
}
|
|
if err := root.checkStructure(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var got []item
|
|
for s := range root.all() {
|
|
got = append(got, item{s, s.path})
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("\ngot %v\nwant %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolveURIs(t *testing.T) {
|
|
for _, baseURI := range []string{"", "http://a.com"} {
|
|
t.Run(baseURI, func(t *testing.T) {
|
|
root := &Schema{
|
|
ID: "http://b.com",
|
|
Items: &Schema{
|
|
ID: "/foo.json",
|
|
},
|
|
Contains: &Schema{
|
|
ID: "/bar.json",
|
|
Anchor: "a",
|
|
DynamicAnchor: "da",
|
|
Items: &Schema{
|
|
Anchor: "b",
|
|
Items: &Schema{
|
|
// An ID shouldn't be a query param, but this tests
|
|
// resolving an ID with its parent.
|
|
ID: "?items",
|
|
Anchor: "c",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
base, err := url.Parse(baseURI)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := resolveURIs(root, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantIDs := map[string]*Schema{
|
|
baseURI: root,
|
|
"http://b.com/foo.json": root.Items,
|
|
"http://b.com/bar.json": root.Contains,
|
|
"http://b.com/bar.json?items": root.Contains.Items.Items,
|
|
}
|
|
if baseURI != root.ID {
|
|
wantIDs[root.ID] = root
|
|
}
|
|
wantAnchors := map[*Schema]map[string]anchorInfo{
|
|
root.Contains: {
|
|
"a": anchorInfo{root.Contains, false},
|
|
"da": anchorInfo{root.Contains, true},
|
|
"b": anchorInfo{root.Contains.Items, false},
|
|
},
|
|
root.Contains.Items.Items: {
|
|
"c": anchorInfo{root.Contains.Items.Items, false},
|
|
},
|
|
}
|
|
|
|
gotKeys := slices.Sorted(maps.Keys(got))
|
|
wantKeys := slices.Sorted(maps.Keys(wantIDs))
|
|
if !slices.Equal(gotKeys, wantKeys) {
|
|
t.Errorf("ID keys:\ngot %q\nwant %q", gotKeys, wantKeys)
|
|
}
|
|
if !maps.Equal(got, wantIDs) {
|
|
t.Errorf("IDs:\ngot %+v\n\nwant %+v", got, wantIDs)
|
|
}
|
|
for s := range root.all() {
|
|
if want := wantAnchors[s]; want != nil {
|
|
if got := s.anchors; !maps.Equal(got, want) {
|
|
t.Errorf("anchors:\ngot %+v\n\nwant %+v", got, want)
|
|
}
|
|
} else if s.anchors != nil {
|
|
t.Errorf("non-nil anchors for %s", s)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefCycle(t *testing.T) {
|
|
// Verify that cycles of refs are OK.
|
|
// The test suite doesn't check this, surprisingly.
|
|
schemas := map[string]*Schema{
|
|
"root": {Ref: "a"},
|
|
"a": {Ref: "b"},
|
|
"b": {Ref: "a"},
|
|
}
|
|
|
|
loader := func(uri *url.URL) (*Schema, error) {
|
|
s, ok := schemas[uri.Path[1:]]
|
|
if !ok {
|
|
return nil, errors.New("not found")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
rs, err := schemas["root"].Resolve(&ResolveOptions{Loader: loader})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
check := func(s *Schema, key string) {
|
|
t.Helper()
|
|
if s.resolvedRef != schemas[key] {
|
|
t.Errorf("%s resolvedRef != schemas[%q]", s.json(), key)
|
|
}
|
|
}
|
|
|
|
check(rs.root, "a")
|
|
check(schemas["a"], "b")
|
|
check(schemas["b"], "a")
|
|
}
|