From c5a35917427d0a2e7d3bd5f0ab672b4b3f668e5a Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 27 May 2025 07:08:57 -0400 Subject: [PATCH] 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 Reviewed-by: Alan Donovan Auto-Submit: Jonathan Amsterdam --- jsonschema/resolve.go | 2 +- jsonschema/schema.go | 20 +++---- jsonschema/schema_test.go | 6 +- jsonschema/validate.go | 116 +++++++++++++++++++++++++++++++++++- jsonschema/validate_test.go | 68 ++++++++++++++++++--- 5 files changed, 187 insertions(+), 25 deletions(-) diff --git a/jsonschema/resolve.go b/jsonschema/resolve.go index 80090a2..9e775d6 100644 --- a/jsonschema/resolve.go +++ b/jsonschema/resolve.go @@ -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 { diff --git a/jsonschema/schema.go b/jsonschema/schema.go index e96af52..a5eee43 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -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 { diff --git a/jsonschema/schema_test.go b/jsonschema/schema_test.go index a973170..0d3ea09 100644 --- a/jsonschema/schema_test.go +++ b/jsonschema/schema_test.go @@ -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) } diff --git a/jsonschema/validate.go b/jsonschema/validate.go index 9ef830b..21819a2 100644 --- a/jsonschema/validate.go +++ b/jsonschema/validate.go @@ -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) } diff --git a/jsonschema/validate_test.go b/jsonschema/validate_test.go index 7a78e5c..ebfbd71 100644 --- a/jsonschema/validate_test.go +++ b/jsonschema/validate_test.go @@ -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.