jsonschema: dynamic references

A dynamic reference is like a dynamic variable in a programming
language, with two twists:

- There must be a dynamic anchor in lexical scope for the ref to
  resolve. (Think of it as declaring the dynamic variable.)

- The ref resolves to the schema highest on the stack, not lowest.
  That feels odd, but it turns out to work for the intended purpose,
  which is to provide extension points for additional features.

There is some distracting code motion and refactoring in this CL:
resolveRefs was moved down in the file, and resolution of a single
ref was split off.

Change-Id: I17da6052e0782fc540d14b8a41b06a2645923872
Reviewed-on: https://go-review.googlesource.com/c/tools/+/674155
Auto-Submit: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
Jonathan Amsterdam 2025-04-26 20:09:26 -04:00 • committed by Gopher Robot
commit 2a09582fa4

View file

@ -35,6 +35,10 @@ func (rs *Resolved) Validate(instance any) error {
type state struct {
rs *Resolved
depth int
// stack holds the schemas from recursive calls to validate.
// These are the "dynamic scopes" used to resolve dynamic references.
// https://json-schema.org/draft/2020-12/json-schema-core#scopes
stack []*Schema
}
// validate validates the reflected value of the instance.
@ -48,10 +52,12 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
}
}()
st.depth++
defer func() { st.depth-- }()
if st.depth >= 100 {
return fmt.Errorf("max recursion depth of %d reached", st.depth)
st.stack = append(st.stack, schema) // push
defer func() {
st.stack = st.stack[:len(st.stack)-1] // pop
}()
if depth := len(st.stack); depth >= 100 {
return fmt.Errorf("max recursion depth of %d reached", depth)
}
// We checked for nil schemas in [Schema.Resolve].
@ -162,6 +168,43 @@ func (st *state) validate(instance reflect.Value, schema *Schema, callerAnns *an
}
}
// $dynamicRef: https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.2
if schema.DynamicRef != "" {
// The ref behaves lexically or dynamically, but not both.
assert((schema.resolvedDynamicRef == nil) != (schema.dynamicRefAnchor == ""),
"DynamicRef not resolved properly")
if schema.resolvedDynamicRef != nil {
// Same as $ref.
if err := st.validate(instance, schema.resolvedDynamicRef, &anns, path); err != nil {
return err
}
} else {
// 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.
var dynamicSchema *Schema
for _, s := range st.stack {
info, ok := s.base.anchors[schema.dynamicRefAnchor]
if ok && info.dynamic {
dynamicSchema = info.schema
break
}
}
if dynamicSchema == nil {
return fmt.Errorf("missing dynamic anchor %q", schema.dynamicRefAnchor)
}
if err := st.validate(instance, dynamicSchema, &anns, path); err != nil {
return err
}
}
}
// logic
// https://json-schema.org/draft/2020-12/json-schema-core#section-10.2
// These must happen before arrays and objects because if they evaluate an item or property,