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:
parent
a9a69a03e4
commit
c5a3591742
5 changed files with 187 additions and 25 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue