internal/mcp/jsonschema: apply schema defaults to an instance

This CL handles only the limited case of defaults on properties.

This is just a start: I need to think more about defaults on other
schema keywords, and I need to set defaults recursively.

As part of this work, I realized that the Schema.Default field is
more usable when represented as a json.RawMessage instead of a *any.

Change-Id: Iedcedb405b52607a312dbd895fa61e7f7f94c6bc
Reviewed-on: https://go-review.googlesource.com/c/tools/+/676938
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Auto-Submit: Jonathan Amsterdam <jba@google.com>
This commit is contained in:
Jonathan Amsterdam 2025-05-27 07:08:57 -04:00 • committed by Gopher Robot
commit c5a3591742
5 changed files with 187 additions and 25 deletions

View file

@ -223,7 +223,7 @@ func (root *Schema) checkStructure() error {
func (s *Schema) checkLocal(report func(error)) {
addf := func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
report(fmt.Errorf("jsonschema.Schema: %s: %s", s.path, msg))
report(fmt.Errorf("jsonschema.Schema: %s: %s", s, msg))
}
if s == nil {

View file

@ -57,13 +57,13 @@ type Schema struct {
Vocabulary map[string]bool `json:"$vocabulary,omitempty"`
// metadata
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default *any `json:"default,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
WriteOnly bool `json:"writeOnly,omitempty"`
Examples []any `json:"examples,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default json.RawMessage `json:"default,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
WriteOnly bool `json:"writeOnly,omitempty"`
Examples []any `json:"examples,omitempty"`
// validation
// Use Type for a single type, or Types for multiple types; never both.
@ -254,7 +254,6 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
ms := struct {
Type json.RawMessage `json:"type,omitempty"`
Const json.RawMessage `json:"const,omitempty"`
Default json.RawMessage `json:"default,omitempty"`
MinLength *integer `json:"minLength,omitempty"`
MaxLength *integer `json:"maxLength,omitempty"`
MinItems *integer `json:"minItems,omitempty"`
@ -298,14 +297,11 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
return json.Unmarshal(raw, p)
}
// Setting Const or Default to a pointer to null will marshal properly, but won't
// Setting Const to a pointer to null will marshal properly, but won't
// unmarshal: the *any is set to nil, not a pointer to nil.
if err := unmarshalAnyPtr(&s.Const, ms.Const); err != nil {
return err
}
if err := unmarshalAnyPtr(&s.Default, ms.Default); err != nil {
return err
}
set := func(dst **int, src *integer) {
if src != nil {

View file

@ -24,17 +24,17 @@ func TestGoRoundTrip(t *testing.T) {
{Const: Ptr(any(nil))},
{Const: Ptr(any([]int{}))},
{Const: Ptr(any(map[string]any{}))},
{Default: Ptr(any(nil))},
{Default: mustMarshal(1)},
{Default: mustMarshal(nil)},
} {
data, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
t.Logf("marshal: %s", data)
var got *Schema
mustUnmarshal(t, data, &got)
if !Equal(got, s) {
t.Errorf("got %+v, want %+v", got, s)
t.Errorf("got %s, want %s", got.json(), s.json())
if got.Const != nil && s.Const != nil {
t.Logf("Consts: got %#v (%[1]T), want %#v (%[2]T)", *got.Const, *s.Const)
}

View file

@ -5,6 +5,7 @@
package jsonschema
import (
"encoding/json"
"fmt"
"hash/maphash"
"iter"
@ -47,7 +48,11 @@ func (rs *Resolved) validateDefaults() error {
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 {
var d any
if err := json.Unmarshal(s.Default, &d); err != nil {
fmt.Errorf("unmarshaling default value of schema %s: %w", s, err)
}
if err := st.validate(reflect.ValueOf(d), s, nil); err != nil {
return err
}
}
@ -68,6 +73,7 @@ type state struct {
func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *annotations) (err error) {
defer wrapf(&err, "validating %s", schema)
// Maintain a stack for dynamic schema resolution.
st.stack = append(st.stack, schema) // push
defer func() {
st.stack = st.stack[:len(st.stack)-1] // pop
@ -536,6 +542,113 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
return nil
}
// resolveDynamicRef returns the schema referred to by the argument schema's
// $dynamicRef value.
// It returns an error if the dynamic reference has no referent.
// If there is no $dynamicRef, resolveDynamicRef returns nil, nil.
// See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2.
func (st *state) resolveDynamicRef(schema *Schema) (*Schema, error) {
if schema.DynamicRef == "" {
return nil, nil
}
// The ref behaves lexically or dynamically, but not both.
assert((schema.resolvedDynamicRef == nil) != (schema.dynamicRefAnchor == ""),
"DynamicRef not statically resolved properly")
if r := schema.resolvedDynamicRef; r != nil {
// Same as $ref.
return r, nil
}
// Dynamic behavior.
// Look for the base of the outermost schema on the stack with this dynamic
// anchor. (Yes, outermost: the one farthest from here. This the opposite
// of how ordinary dynamic variables behave.)
// Why the base of the schema being validated and not the schema itself?
// Because the base is the scope for anchors. In fact it's possible to
// refer to a schema that is not on the stack, but a child of some base
// on the stack.
// For an example, search for "detached" in testdata/draft2020-12/dynamicRef.json.
for _, s := range st.stack {
info, ok := s.base.anchors[schema.dynamicRefAnchor]
if ok && info.dynamic {
return info.schema, nil
}
}
return nil, fmt.Errorf("missing dynamic anchor %q", schema.dynamicRefAnchor)
}
// ApplyDefaults modifies an instance by applying the schema's defaults to it. If
// a schema or sub-schema has a default, then a corresponding zero instance value
// is set to the default.
//
// The JSON Schema specification does not describe how defaults should be interpreted.
// This method honors defaults only on properties, and only those that are not required.
// If the instance is a map and the property is missing, the property is added to
// the map with the default.
// If the instance is a struct, the field corresponding to the property exists, and
// its value is zero, the field is set to the default.
// ApplyDefaults can panic if a default cannot be assigned to a field.
//
// The argument must be a pointer to the instance.
// (In case we decide that top-level defaults are meaningful.)
//
// It is recommended to first call Resolve with a ValidateDefaults option of true,
// then call this method, and lastly call Validate.
//
// TODO(jba): consider what defaults on top-level or array instances might mean.
// TODO(jba): follow $ref and $dynamicRef
// TODO(jba): apply defaults on sub-schemas to corresponding sub-instances.
func (rs *Resolved) ApplyDefaults(instancep any) error {
st := &state{rs: rs}
return st.applyDefaults(reflect.ValueOf(instancep), rs.root)
}
// Leave this as a potentially recursive helper function, because we'll surely want
// to apply defaults on sub-schemas someday.
func (st *state) applyDefaults(instancep reflect.Value, schema *Schema) (err error) {
defer wrapf(&err, "applyDefaults: schema %s, instance %v", schema, instancep)
instance := instancep.Elem()
if instance.Kind() == reflect.Map || instance.Kind() == reflect.Struct {
if instance.Kind() == reflect.Map {
if kt := instance.Type().Key(); kt.Kind() != reflect.String {
return fmt.Errorf("map key type %s is not a string", kt)
}
}
for prop, subschema := range schema.Properties {
// Ignore defaults on required properties. (A required property shouldn't have a default.)
if schema.isRequired[prop] {
continue
}
val := property(instance, prop)
switch instance.Kind() {
case reflect.Map:
// If there is a default for this property, and the map key is missing,
// set the map value to the default.
if subschema.Default != nil && !val.IsValid() {
// Create an lvalue, since map values aren't addressable.
lvalue := reflect.New(instance.Type().Elem())
if err := json.Unmarshal(subschema.Default, lvalue.Interface()); err != nil {
return err
}
instance.SetMapIndex(reflect.ValueOf(prop), lvalue.Elem())
}
case reflect.Struct:
// If there is a default for this property, and the field exists but is zero,
// set the field to the default.
if subschema.Default != nil && val.IsValid() && val.IsZero() {
if err := json.Unmarshal(subschema.Default, val.Addr().Interface()); err != nil {
return err
}
}
default:
panic(fmt.Sprintf("applyDefaults: property %s: bad value %s of kind %s",
prop, instance, instance.Kind()))
}
}
}
return nil
}
// property returns the value of the property of v with the given name, or the invalid
// reflect.Value if there is none.
// If v is a map, the property is the value of the map whose key is name.
@ -548,6 +661,7 @@ func property(v reflect.Value, name string) reflect.Value {
return v.MapIndex(reflect.ValueOf(name))
case reflect.Struct:
props := structPropertiesOf(v.Type())
// Ignore nonexistent properties.
if index, ok := props[name]; ok {
return v.FieldByIndex(index)
}

View file

@ -94,14 +94,12 @@ 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")},
"a": {Type: "integer", Default: mustMarshal(1)},
"b": {Type: "string", Default: mustMarshal("s")},
},
Default: anyptr(map[string]any{"a": 1, "b": "two"}),
Default: mustMarshal(map[string]any{"a": 1, "b": "two"}),
}
if _, err := s.Resolve(&ResolveOptions{ValidateDefaults: true}); err != nil {
t.Fatal(err)
@ -109,10 +107,10 @@ func TestValidateDefaults(t *testing.T) {
s = &Schema{
Properties: map[string]*Schema{
"a": {Type: "integer", Default: anyptr(3)},
"b": {Type: "string", Default: anyptr("s")},
"a": {Type: "integer", Default: mustMarshal(3)},
"b": {Type: "string", Default: mustMarshal("s")},
},
Default: anyptr(map[string]any{"a": 1, "b": 2}),
Default: mustMarshal(map[string]any{"a": 1, "b": 2}),
}
_, err := s.Resolve(&ResolveOptions{ValidateDefaults: true})
want := `has type "integer", want "string"`
@ -121,6 +119,52 @@ func TestValidateDefaults(t *testing.T) {
}
}
func TestApplyDefaults(t *testing.T) {
schema := &Schema{
Properties: map[string]*Schema{
"A": {Default: mustMarshal(1)},
"B": {Default: mustMarshal(2)},
"C": {Default: mustMarshal(3)},
},
Required: []string{"C"},
}
rs, err := schema.Resolve(&ResolveOptions{ValidateDefaults: true})
if err != nil {
t.Fatal(err)
}
type S struct{ A, B, C int }
for _, tt := range []struct {
instancep any // pointer to instance value
want any // desired value (not a pointer)
}{
{
&map[string]any{"B": 0},
map[string]any{
"A": float64(1), // filled from default
"B": 0, // untouched: it was already there
// "C" not added: it is required (Validate will catch that)
},
},
{
&S{B: 1},
S{
A: 1, // filled from default
B: 1, // untouched: non-zero
C: 0, // untouched: required
},
},
} {
if err := rs.ApplyDefaults(tt.instancep); err != nil {
t.Fatal(err)
}
got := reflect.ValueOf(tt.instancep).Elem().Interface() // dereference the pointer
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("\ngot %#v\nwant %#v", got, tt.want)
}
}
}
func TestStructInstance(t *testing.T) {
instance := struct {
I int
@ -235,6 +279,14 @@ func TestJSONName(t *testing.T) {
}
}
func mustMarshal(x any) json.RawMessage {
data, err := json.Marshal(x)
if err != nil {
panic(err)
}
return json.RawMessage(data)
}
// 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.