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>
262 lines
6.3 KiB
Go
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
|
|
}
|