go-sdk/jsonschema/validate_test.go
Jonathan Amsterdam 9e88f4ff68 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>
2025-05-28 07:50:15 -07:00

262 lines
6.3 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 (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The test for validation uses the official test suite, expressed as a set of JSON files.
// Each file is an array of group objects.
// A testGroup consists of a schema and some tests on it.
type testGroup struct {
Description string
Schema *Schema
Tests []test
}
// A test consists of a JSON instance to be validated and the expected result.
type test struct {
Description string
Data any
Valid bool
}
func TestValidate(t *testing.T) {
files, err := filepath.Glob(filepath.FromSlash("testdata/draft2020-12/*.json"))
if err != nil {
t.Fatal(err)
}
if len(files) == 0 {
t.Fatal("no files")
}
for _, file := range files {
base := filepath.Base(file)
t.Run(base, func(t *testing.T) {
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
var groups []testGroup
if err := json.Unmarshal(data, &groups); err != nil {
t.Fatal(err)
}
for _, g := range groups {
t.Run(g.Description, func(t *testing.T) {
rs, err := g.Schema.Resolve(&ResolveOptions{Loader: loadRemote})
if err != nil {
t.Fatal(err)
}
for _, test := range g.Tests {
t.Run(test.Description, func(t *testing.T) {
err = rs.Validate(test.Data)
if err != nil && test.Valid {
t.Errorf("wanted success, but failed with: %v", err)
}
if err == nil && !test.Valid {
t.Error("succeeded but wanted failure")
}
if t.Failed() {
t.Errorf("schema: %s", g.Schema.json())
t.Fatalf("instance: %v (%[1]T)", test.Data)
}
})
}
})
}
})
}
}
func TestValidateErrors(t *testing.T) {
schema := &Schema{
PrefixItems: []*Schema{{Contains: &Schema{Type: "integer"}}},
}
rs, err := schema.Resolve(nil)
if err != nil {
t.Fatal(err)
}
err = rs.Validate([]any{[]any{"1"}})
want := "prefixItems/0"
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("error:\n%s\ndoes not contain %q", err, want)
}
}
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
B bool `json:"b"`
P *int // either missing or nil
u int // unexported: not a property
}{1, true, nil, 0}
for _, tt := range []struct {
s Schema
want bool
}{
{
Schema{MinProperties: Ptr(4)},
false,
},
{
Schema{MinProperties: Ptr(3)},
true, // P interpreted as present
},
{
Schema{MaxProperties: Ptr(1)},
false,
},
{
Schema{MaxProperties: Ptr(2)},
true, // P interpreted as absent
},
{
Schema{Required: []string{"i"}}, // the name is "I"
false,
},
{
Schema{Required: []string{"B"}}, // the name is "b"
false,
},
{
Schema{PropertyNames: &Schema{MinLength: Ptr(2)}},
false,
},
{
Schema{Properties: map[string]*Schema{"b": {Type: "boolean"}}},
true,
},
{
Schema{Properties: map[string]*Schema{"b": {Type: "number"}}},
false,
},
{
Schema{Required: []string{"I"}},
true,
},
{
Schema{Required: []string{"I", "P"}},
true, // P interpreted as present
},
{
Schema{Required: []string{"I", "P"}, Properties: map[string]*Schema{"P": {Type: "number"}}},
false, // P interpreted as present, but not a number
},
{
Schema{Required: []string{"I"}, Properties: map[string]*Schema{"P": {Type: "number"}}},
true, // P not required, so interpreted as absent
},
{
Schema{Required: []string{"I"}, AdditionalProperties: falseSchema()},
false,
},
{
Schema{DependentRequired: map[string][]string{"b": {"u"}}},
false,
},
{
Schema{DependentSchemas: map[string]*Schema{"b": falseSchema()}},
false,
},
{
Schema{UnevaluatedProperties: falseSchema()},
false,
},
} {
res, err := tt.s.Resolve(nil)
if err != nil {
t.Fatal(err)
}
err = res.Validate(instance)
if err == nil && !tt.want {
t.Errorf("succeeded unexpectedly\nschema = %s", tt.s.json())
} else if err != nil && tt.want {
t.Errorf("Validate: %v\nschema = %s", err, tt.s.json())
}
}
}
func TestJSONName(t *testing.T) {
type S struct {
A int
B int `json:","`
C int `json:"-"`
D int `json:"-,"`
E int `json:"echo"`
F int `json:"foxtrot,omitempty"`
g int `json:"golf"`
}
want := []string{"A", "B", "", "-", "echo", "foxtrot", ""}
tt := reflect.TypeFor[S]()
for i := range tt.NumField() {
got, _ := jsonName(tt.Field(i))
if got != want[i] {
t.Errorf("got %q, want %q", got, want[i])
}
}
}
// loadRemote loads a remote reference used in the test suite.
func loadRemote(uri *url.URL) (*Schema, error) {
// Anything with localhost:1234 refers to the remotes directory in the test suite repo.
if uri.Host == "localhost:1234" {
return loadSchemaFromFile(filepath.FromSlash(filepath.Join("testdata/remotes", uri.Path)))
}
// One test needs the meta-schema files.
const metaPrefix = "https://json-schema.org/draft/2020-12/"
if after, ok := strings.CutPrefix(uri.String(), metaPrefix); ok {
return loadSchemaFromFile(filepath.FromSlash("meta-schemas/draft2020-12/" + after + ".json"))
}
return nil, fmt.Errorf("don't know how to load %s", uri)
}
func loadSchemaFromFile(filename string) (*Schema, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var s Schema
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("unmarshaling JSON at %s: %w", filename, err)
}
return &s, nil
}