diff --git a/jsonschema/resolve.go b/jsonschema/resolve.go index 9d3a064..9fb8826 100644 --- a/jsonschema/resolve.go +++ b/jsonschema/resolve.go @@ -102,7 +102,7 @@ func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) { // which may differ if the schema has an $id. // We must set the map before calling resolveRefs, or ref cycles will cause unbounded recursion. r.loaded[baseURI.String()] = rs - r.loaded[s.baseURI.String()] = rs + r.loaded[s.uri.String()] = rs if err := r.resolveRefs(rs); err != nil { return nil, err @@ -110,57 +110,6 @@ func (r *resolver) resolve(s *Schema, baseURI *url.URL) (*Resolved, error) { return rs, nil } -// resolveRefs replaces all refs in the schemas with the schema they refer to. -// A reference that doesn't resolve within the schema may refer to some other schema -// that needs to be loaded. -func (r *resolver) resolveRefs(rs *Resolved) error { - for s := range rs.root.all() { - if s.Ref == "" { - continue - } - refURI, err := url.Parse(s.Ref) - if err != nil { - return err - } - // URI-resolve the ref against the current base URI to get a complete URI. - refURI = s.baseURI.ResolveReference(refURI) - // The non-fragment part of a ref URI refers to the base URI of some schema. - u := *refURI - u.Fragment = "" - fraglessRefURI := &u - // Look it up locally. - referencedSchema := rs.resolvedURIs[fraglessRefURI.String()] - if referencedSchema == nil { - // The schema is remote. Maybe we've already loaded it. - // We assume that the non-fragment part of refURI refers to a top-level schema - // document. That is, we don't support the case exemplified by - // http://foo.com/bar.json/baz, where the document is in bar.json and - // the reference points to a subschema within it. - // TODO: support that case. - loadedResolved := r.loaded[fraglessRefURI.String()] - if loadedResolved == nil { - // Try to load the schema. - ls, err := r.loader(fraglessRefURI) - if err != nil { - return fmt.Errorf("loading %s: %w", fraglessRefURI, err) - } - loadedResolved, err = r.resolve(ls, fraglessRefURI) - if err != nil { - return err - } - } - referencedSchema = loadedResolved.root - assert(referencedSchema != nil, "nil referenced schema") - } - // The fragment selects the referenced schema, or a subschema of it. - s.resolvedRef, err = lookupFragment(referencedSchema, refURI.Fragment) - if err != nil { - return err - } - } - return nil -} - func (root *Schema) check() error { if root == nil { return errors.New("nil schema") @@ -267,10 +216,7 @@ func resolveURIs(root *Schema, baseURI *url.URL) (map[string]*Schema, error) { var resolve func(s, base *Schema) error resolve = func(s, base *Schema) error { // ids are scoped to the root. - if s.ID == "" { - // If a schema doesn't have an $id, its base is the parent base. - s.baseURI = base.baseURI - } else { + if s.ID != "" { // A non-empty ID establishes a new base. idURI, err := url.Parse(s.ID) if err != nil { @@ -280,26 +226,33 @@ func resolveURIs(root *Schema, baseURI *url.URL) (map[string]*Schema, error) { return fmt.Errorf("$id %s must not have a fragment", s.ID) } // The base URI for this schema is its $id resolved against the parent base. - s.baseURI = base.baseURI.ResolveReference(idURI) - if !s.baseURI.IsAbs() { - return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %s)", s.ID, s.baseURI) + s.uri = base.uri.ResolveReference(idURI) + if !s.uri.IsAbs() { + return fmt.Errorf("$id %s does not resolve to an absolute URI (base is %s)", s.ID, s.base.uri) } - resolvedURIs[s.baseURI.String()] = s + resolvedURIs[s.uri.String()] = s base = s // needed for anchors } + s.base = base - // Anchors are URI fragments that are scoped to their base. + // Anchors and dynamic anchors are URI fragments that are scoped to their base. // We treat them as keys in a map stored within the schema. - if s.Anchor != "" { - if base.anchors[s.Anchor] != nil { - return fmt.Errorf("duplicate anchor %q in %s", s.Anchor, base.baseURI) + setAnchor := func(anchor string, dynamic bool) error { + if anchor != "" { + if _, ok := base.anchors[anchor]; ok { + return fmt.Errorf("duplicate anchor %q in %s", anchor, base.uri) + } + if base.anchors == nil { + base.anchors = map[string]anchorInfo{} + } + base.anchors[anchor] = anchorInfo{s, dynamic} } - if base.anchors == nil { - base.anchors = map[string]*Schema{} - } - base.anchors[s.Anchor] = s + return nil } + setAnchor(s.Anchor, false) + setAnchor(s.DynamicAnchor, true) + for c := range s.children() { if err := resolve(c, base); err != nil { return err @@ -308,8 +261,8 @@ func resolveURIs(root *Schema, baseURI *url.URL) (map[string]*Schema, error) { return nil } - // Set the root URI to the base for now. If the root has an $id, the base will change. - root.baseURI = baseURI + // Set the root URI to the base for now. If the root has an $id, this will change. + root.uri = baseURI // The original base, even if changed, is still a valid way to refer to the root. resolvedURIs[baseURI.String()] = root if err := resolve(root, root); err != nil { @@ -318,18 +271,95 @@ func resolveURIs(root *Schema, baseURI *url.URL) (map[string]*Schema, error) { return resolvedURIs, nil } -// lookupFragment returns the schema referenced by frag in s, or an error -// if there isn't one or something else went wrong. -func lookupFragment(s *Schema, frag string) (*Schema, error) { +// resolveRefs replaces every ref in the schemas with the schema it refers to. +// A reference that doesn't resolve within the schema may refer to some other schema +// that needs to be loaded. +func (r *resolver) resolveRefs(rs *Resolved) error { + for s := range rs.root.all() { + if s.Ref != "" { + refSchema, _, err := r.resolveRef(rs, s, s.Ref) + if err != nil { + return err + } + // Whether or not the anchor referred to by $ref fragment is dynamic, + // the ref still treats it lexically. + s.resolvedRef = refSchema + } + if s.DynamicRef != "" { + refSchema, frag, err := r.resolveRef(rs, s, s.DynamicRef) + if err != nil { + return err + } + if frag != "" { + // The dynamic ref's fragment points to a dynamic anchor. + // We must resolve the fragment at validation time. + s.dynamicRefAnchor = frag + } else { + // There is no dynamic anchor in the lexically referenced schema, + // so the dynamic ref behaves like a lexical ref. + s.resolvedDynamicRef = refSchema + } + } + } + return nil +} + +// resolveRef resolves the reference ref, which is either s.Ref or s.DynamicRef. +func (r *resolver) resolveRef(rs *Resolved, s *Schema, ref string) (_ *Schema, dynamicFragment string, err error) { + refURI, err := url.Parse(ref) + if err != nil { + return nil, "", err + } + // URI-resolve the ref against the current base URI to get a complete URI. + refURI = s.base.uri.ResolveReference(refURI) + // The non-fragment part of a ref URI refers to the base URI of some schema. + // This part is the same for dynamic refs too: their non-fragment part resolves + // lexically. + u := *refURI + u.Fragment = "" + fraglessRefURI := &u + // Look it up locally. + referencedSchema := rs.resolvedURIs[fraglessRefURI.String()] + if referencedSchema == nil { + // The schema is remote. Maybe we've already loaded it. + // We assume that the non-fragment part of refURI refers to a top-level schema + // document. That is, we don't support the case exemplified by + // http://foo.com/bar.json/baz, where the document is in bar.json and + // the reference points to a subschema within it. + // TODO: support that case. + if lrs := r.loaded[fraglessRefURI.String()]; lrs != nil { + referencedSchema = lrs.root + } else { + // Try to load the schema. + ls, err := r.loader(fraglessRefURI) + if err != nil { + return nil, "", fmt.Errorf("loading %s: %w", fraglessRefURI, err) + } + lrs, err := r.resolve(ls, fraglessRefURI) + if err != nil { + return nil, "", err + } + referencedSchema = lrs.root + assert(referencedSchema != nil, "nil referenced schema") + } + } + + frag := refURI.Fragment + // Look up frag in refSchema. // frag is either a JSON Pointer or the name of an anchor. // A JSON Pointer is either the empty string or begins with a '/', // whereas anchors are always non-empty strings that don't contain slashes. if frag != "" && !strings.HasPrefix(frag, "/") { - if fs := s.anchors[frag]; fs != nil { - return fs, nil + info, found := referencedSchema.anchors[frag] + if !found { + return nil, "", fmt.Errorf("no anchor %q in %s", frag, s) } - return nil, fmt.Errorf("no anchor %q in %s", frag, s) + if info.dynamic { + dynamicFragment = frag + } + return info.schema, dynamicFragment, nil } - // frag is a JSON Pointer. Follow it. - return dereferenceJSONPointer(s, frag) + // frag is a JSON Pointer. + s, err = dereferenceJSONPointer(referencedSchema, frag) + return s, "", err } diff --git a/jsonschema/resolve_test.go b/jsonschema/resolve_test.go index 610d8de..3b15094 100644 --- a/jsonschema/resolve_test.go +++ b/jsonschema/resolve_test.go @@ -66,8 +66,9 @@ func TestResolveURIs(t *testing.T) { ID: "/foo.json", }, Contains: &Schema{ - ID: "/bar.json", - Anchor: "a", + ID: "/bar.json", + Anchor: "a", + DynamicAnchor: "da", Items: &Schema{ Anchor: "b", Items: &Schema{ @@ -97,9 +98,15 @@ func TestResolveURIs(t *testing.T) { if baseURI != root.ID { wantIDs[root.ID] = root } - wantAnchors := map[*Schema]map[string]*Schema{ - root.Contains: {"a": root.Contains, "b": root.Contains.Items}, - root.Contains.Items.Items: {"c": root.Contains.Items.Items}, + wantAnchors := map[*Schema]map[string]anchorInfo{ + root.Contains: { + "a": anchorInfo{root.Contains, false}, + "da": anchorInfo{root.Contains, true}, + "b": anchorInfo{root.Contains.Items, false}, + }, + root.Contains.Items.Items: { + "c": anchorInfo{root.Contains.Items.Items, false}, + }, } gotKeys := slices.Sorted(maps.Keys(got)) diff --git a/jsonschema/schema.go b/jsonschema/schema.go index 76b971b..e87dd33 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -8,6 +8,7 @@ package jsonschema import ( "bytes" + "cmp" "encoding/json" "errors" "fmt" @@ -110,15 +111,35 @@ type Schema struct { DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty"` // computed fields - // If the schema doesn't have an ID, the base URI is that of its parent. - // Otherwise, the base URI is the ID resolved against the parent's baseURI. - // The parent base URI at top level is where the schema was loaded from, or - // if not loaded, then it should be provided to Schema.Resolve. - baseURI *url.URL + + // This schema's base schema. + // If the schema is the root or has an ID, its base is itself. + // Otherwise, its base is the innermost enclosing schema whose base + // is itself. + // Intuitively, a base schema is one that can be referred to with a + // fragmentless URI. + base *Schema + + // The URI for the schema, if it is the root or has an ID. + // Otherwise nil. + // Invariants: + // s.base.uri != nil. + // s.base == s <=> s.uri != nil + uri *url.URL + // The schema to which Ref refers. resolvedRef *Schema - // map from anchors to subschemas - anchors map[string]*Schema + + // If the schema has a dynamic ref, exactly one of the next two fields + // will be non-zero after successful resolution. + // The schema to which the dynamic ref refers when it acts lexically. + resolvedDynamicRef *Schema + // The anchor to look up on the stack when the dynamic ref acts dynamically. + dynamicRefAnchor string + + // Map from anchors to subschemas. + anchors map[string]anchorInfo + // compiled regexps pattern *regexp.Regexp patternProperties map[*regexp.Regexp]*Schema @@ -129,10 +150,20 @@ func falseSchema() *Schema { return &Schema{Not: &Schema{}} } +// anchorInfo records the subschema to which an anchor refers, and whether +// the anchor keyword is $anchor or $dynamicAnchor. +type anchorInfo struct { + schema *Schema + dynamic bool +} + // String returns a short description of the schema. func (s *Schema) String() string { - if s.ID != "" { - return s.ID + if s.uri != nil { + return s.uri.String() + } + if a := cmp.Or(s.Anchor, s.DynamicAnchor); a != "" { + return fmt.Sprintf("%q, anchor %s", s.base.uri.String(), a) } // TODO: return something better, like a JSON Pointer from the base. return "" diff --git a/jsonschema/testdata/draft2020-12/anchor.json b/jsonschema/testdata/draft2020-12/anchor.json new file mode 100644 index 0000000..99143fa --- /dev/null +++ b/jsonschema/testdata/draft2020-12/anchor.json @@ -0,0 +1,120 @@ +[ + { + "description": "Location-independent identifier", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#foo", + "$defs": { + "A": { + "$anchor": "foo", + "type": "integer" + } + } + }, + "tests": [ + { + "data": 1, + "description": "match", + "valid": true + }, + { + "data": "a", + "description": "mismatch", + "valid": false + } + ] + }, + { + "description": "Location-independent identifier with absolute URI", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/bar#foo", + "$defs": { + "A": { + "$id": "http://localhost:1234/draft2020-12/bar", + "$anchor": "foo", + "type": "integer" + } + } + }, + "tests": [ + { + "data": 1, + "description": "match", + "valid": true + }, + { + "data": "a", + "description": "mismatch", + "valid": false + } + ] + }, + { + "description": "Location-independent identifier with base URI change in subschema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/root", + "$ref": "http://localhost:1234/draft2020-12/nested.json#foo", + "$defs": { + "A": { + "$id": "nested.json", + "$defs": { + "B": { + "$anchor": "foo", + "type": "integer" + } + } + } + } + }, + "tests": [ + { + "data": 1, + "description": "match", + "valid": true + }, + { + "data": "a", + "description": "mismatch", + "valid": false + } + ] + }, + { + "description": "same $anchor with different base uri", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/foobar", + "$defs": { + "A": { + "$id": "child1", + "allOf": [ + { + "$id": "child2", + "$anchor": "my_anchor", + "type": "number" + }, + { + "$anchor": "my_anchor", + "type": "string" + } + ] + } + }, + "$ref": "child1#my_anchor" + }, + "tests": [ + { + "description": "$ref resolves to /$defs/A/allOf/1", + "data": "a", + "valid": true + }, + { + "description": "$ref does not resolve to /$defs/A/allOf/0", + "data": 1, + "valid": false + } + ] + } +] diff --git a/jsonschema/testdata/draft2020-12/defs.json b/jsonschema/testdata/draft2020-12/defs.json new file mode 100644 index 0000000..da2a503 --- /dev/null +++ b/jsonschema/testdata/draft2020-12/defs.json @@ -0,0 +1,21 @@ +[ + { + "description": "validate definition against metaschema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + "tests": [ + { + "description": "valid definition schema", + "data": {"$defs": {"foo": {"type": "integer"}}}, + "valid": true + }, + { + "description": "invalid definition schema", + "data": {"$defs": {"foo": {"type": 1}}}, + "valid": false + } + ] + } +] diff --git a/jsonschema/testdata/draft2020-12/dynamicRef.json b/jsonschema/testdata/draft2020-12/dynamicRef.json new file mode 100644 index 0000000..ffa211b --- /dev/null +++ b/jsonschema/testdata/draft2020-12/dynamicRef.json @@ -0,0 +1,815 @@ +[ + { + "description": "A $dynamicRef to a $dynamicAnchor in the same schema resource behaves like a normal $ref to an $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamicRef-dynamicAnchor-same-schema/root", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + } + } + }, + "tests": [ + { + "description": "An array of strings is valid", + "data": ["foo", "bar"], + "valid": true + }, + { + "description": "An array containing non-strings is invalid", + "data": ["foo", 42], + "valid": false + } + ] + }, + { + "description": "A $dynamicRef to an $anchor in the same schema resource behaves like a normal $ref to an $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamicRef-anchor-same-schema/root", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "foo": { + "$anchor": "items", + "type": "string" + } + } + }, + "tests": [ + { + "description": "An array of strings is valid", + "data": ["foo", "bar"], + "valid": true + }, + { + "description": "An array containing non-strings is invalid", + "data": ["foo", 42], + "valid": false + } + ] + }, + { + "description": "A $ref to a $dynamicAnchor in the same schema resource behaves like a normal $ref to an $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/ref-dynamicAnchor-same-schema/root", + "type": "array", + "items": { "$ref": "#items" }, + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + } + } + }, + "tests": [ + { + "description": "An array of strings is valid", + "data": ["foo", "bar"], + "valid": true + }, + { + "description": "An array containing non-strings is invalid", + "data": ["foo", 42], + "valid": false + } + ] + }, + { + "description": "A $dynamicRef resolves to the first $dynamicAnchor still in scope that is encountered when the schema is evaluated", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/typical-dynamic-resolution/root", + "$ref": "list", + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "items": { + "$comment": "This is only needed to satisfy the bookending requirement", + "$dynamicAnchor": "items" + } + } + } + } + }, + "tests": [ + { + "description": "An array of strings is valid", + "data": ["foo", "bar"], + "valid": true + }, + { + "description": "An array containing non-strings is invalid", + "data": ["foo", 42], + "valid": false + } + ] + }, + { + "description": "A $dynamicRef without anchor in fragment behaves identical to $ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamicRef-without-anchor/root", + "$ref": "list", + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#/$defs/items" }, + "$defs": { + "items": { + "$comment": "This is only needed to satisfy the bookending requirement", + "$dynamicAnchor": "items", + "type": "number" + } + } + } + } + }, + "tests": [ + { + "description": "An array of strings is invalid", + "data": ["foo", "bar"], + "valid": false + }, + { + "description": "An array of numbers is valid", + "data": [24, 42], + "valid": true + } + ] + }, + { + "description": "A $dynamicRef with intermediate scopes that don't include a matching $dynamicAnchor does not affect dynamic scope resolution", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-resolution-with-intermediate-scopes/root", + "$ref": "intermediate-scope", + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + }, + "intermediate-scope": { + "$id": "intermediate-scope", + "$ref": "list" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "items": { + "$comment": "This is only needed to satisfy the bookending requirement", + "$dynamicAnchor": "items" + } + } + } + } + }, + "tests": [ + { + "description": "An array of strings is valid", + "data": ["foo", "bar"], + "valid": true + }, + { + "description": "An array containing non-strings is invalid", + "data": ["foo", 42], + "valid": false + } + ] + }, + { + "description": "An $anchor with the same name as a $dynamicAnchor is not used for dynamic scope resolution", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-resolution-ignores-anchors/root", + "$ref": "list", + "$defs": { + "foo": { + "$anchor": "items", + "type": "string" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "items": { + "$comment": "This is only needed to satisfy the bookending requirement", + "$dynamicAnchor": "items" + } + } + } + } + }, + "tests": [ + { + "description": "Any array is valid", + "data": ["foo", 42], + "valid": true + } + ] + }, + { + "description": "A $dynamicRef without a matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-resolution-without-bookend/root", + "$ref": "list", + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "items": { + "$comment": "This is only needed to give the reference somewhere to resolve to when it behaves like $ref", + "$anchor": "items" + } + } + } + } + }, + "tests": [ + { + "description": "Any array is valid", + "data": ["foo", 42], + "valid": true + } + ] + }, + { + "description": "A $dynamicRef with a non-matching $dynamicAnchor in the same schema resource behaves like a normal $ref to $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/unmatched-dynamic-anchor/root", + "$ref": "list", + "$defs": { + "foo": { + "$dynamicAnchor": "items", + "type": "string" + }, + "list": { + "$id": "list", + "type": "array", + "items": { "$dynamicRef": "#items" }, + "$defs": { + "items": { + "$comment": "This is only needed to give the reference somewhere to resolve to when it behaves like $ref", + "$anchor": "items", + "$dynamicAnchor": "foo" + } + } + } + } + }, + "tests": [ + { + "description": "Any array is valid", + "data": ["foo", 42], + "valid": true + } + ] + }, + { + "description": "A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor resolves to the first $dynamicAnchor in the dynamic scope", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/relative-dynamic-reference/root", + "$dynamicAnchor": "meta", + "type": "object", + "properties": { + "foo": { "const": "pass" } + }, + "$ref": "extended", + "$defs": { + "extended": { + "$id": "extended", + "$dynamicAnchor": "meta", + "type": "object", + "properties": { + "bar": { "$ref": "bar" } + } + }, + "bar": { + "$id": "bar", + "type": "object", + "properties": { + "baz": { "$dynamicRef": "extended#meta" } + } + } + } + }, + "tests": [ + { + "description": "The recursive part is valid against the root", + "data": { + "foo": "pass", + "bar": { + "baz": { "foo": "pass" } + } + }, + "valid": true + }, + { + "description": "The recursive part is not valid against the root", + "data": { + "foo": "pass", + "bar": { + "baz": { "foo": "fail" } + } + }, + "valid": false + } + ] + }, + { + "description": "A $dynamicRef that initially resolves to a schema without a matching $dynamicAnchor behaves like a normal $ref to $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/relative-dynamic-reference-without-bookend/root", + "$dynamicAnchor": "meta", + "type": "object", + "properties": { + "foo": { "const": "pass" } + }, + "$ref": "extended", + "$defs": { + "extended": { + "$id": "extended", + "$anchor": "meta", + "type": "object", + "properties": { + "bar": { "$ref": "bar" } + } + }, + "bar": { + "$id": "bar", + "type": "object", + "properties": { + "baz": { "$dynamicRef": "extended#meta" } + } + } + } + }, + "tests": [ + { + "description": "The recursive part doesn't need to validate against the root", + "data": { + "foo": "pass", + "bar": { + "baz": { "foo": "fail" } + } + }, + "valid": true + } + ] + }, + { + "description": "multiple dynamic paths to the $dynamicRef keyword", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-ref-with-multiple-paths/main", + "if": { + "properties": { + "kindOfList": { "const": "numbers" } + }, + "required": ["kindOfList"] + }, + "then": { "$ref": "numberList" }, + "else": { "$ref": "stringList" }, + + "$defs": { + "genericList": { + "$id": "genericList", + "properties": { + "list": { + "items": { "$dynamicRef": "#itemType" } + } + }, + "$defs": { + "defaultItemType": { + "$comment": "Only needed to satisfy bookending requirement", + "$dynamicAnchor": "itemType" + } + } + }, + "numberList": { + "$id": "numberList", + "$defs": { + "itemType": { + "$dynamicAnchor": "itemType", + "type": "number" + } + }, + "$ref": "genericList" + }, + "stringList": { + "$id": "stringList", + "$defs": { + "itemType": { + "$dynamicAnchor": "itemType", + "type": "string" + } + }, + "$ref": "genericList" + } + } + }, + "tests": [ + { + "description": "number list with number values", + "data": { + "kindOfList": "numbers", + "list": [1.1] + }, + "valid": true + }, + { + "description": "number list with string values", + "data": { + "kindOfList": "numbers", + "list": ["foo"] + }, + "valid": false + }, + { + "description": "string list with number values", + "data": { + "kindOfList": "strings", + "list": [1.1] + }, + "valid": false + }, + { + "description": "string list with string values", + "data": { + "kindOfList": "strings", + "list": ["foo"] + }, + "valid": true + } + ] + }, + { + "description": "after leaving a dynamic scope, it is not used by a $dynamicRef", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-ref-leaving-dynamic-scope/main", + "if": { + "$id": "first_scope", + "$defs": { + "thingy": { + "$comment": "this is first_scope#thingy", + "$dynamicAnchor": "thingy", + "type": "number" + } + } + }, + "then": { + "$id": "second_scope", + "$ref": "start", + "$defs": { + "thingy": { + "$comment": "this is second_scope#thingy, the final destination of the $dynamicRef", + "$dynamicAnchor": "thingy", + "type": "null" + } + } + }, + "$defs": { + "start": { + "$comment": "this is the landing spot from $ref", + "$id": "start", + "$dynamicRef": "inner_scope#thingy" + }, + "thingy": { + "$comment": "this is the first stop for the $dynamicRef", + "$id": "inner_scope", + "$dynamicAnchor": "thingy", + "type": "string" + } + } + }, + "tests": [ + { + "description": "string matches /$defs/thingy, but the $dynamicRef does not stop here", + "data": "a string", + "valid": false + }, + { + "description": "first_scope is not in dynamic scope for the $dynamicRef", + "data": 42, + "valid": false + }, + { + "description": "/then/$defs/thingy is the final stop for the $dynamicRef", + "data": null, + "valid": true + } + ] + }, + { + "description": "strict-tree schema, guards against misspelled properties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/strict-tree.json", + "$dynamicAnchor": "node", + + "$ref": "tree.json", + "unevaluatedProperties": false + }, + "tests": [ + { + "description": "instance with misspelled field", + "data": { + "children": [{ + "daat": 1 + }] + }, + "valid": false + }, + { + "description": "instance with correct field", + "data": { + "children": [{ + "data": 1 + }] + }, + "valid": true + } + ] + }, + { + "description": "tests for implementation dynamic anchor and reference link", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/strict-extendible.json", + "$ref": "extendible-dynamic-ref.json", + "$defs": { + "elements": { + "$dynamicAnchor": "elements", + "properties": { + "a": true + }, + "required": ["a"], + "additionalProperties": false + } + } + }, + "tests": [ + { + "description": "incorrect parent schema", + "data": { + "a": true + }, + "valid": false + }, + { + "description": "incorrect extended schema", + "data": { + "elements": [ + { "b": 1 } + ] + }, + "valid": false + }, + { + "description": "correct extended schema", + "data": { + "elements": [ + { "a": 1 } + ] + }, + "valid": true + } + ] + }, + { + "description": "$ref and $dynamicAnchor are independent of order - $defs first", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/strict-extendible-allof-defs-first.json", + "allOf": [ + { + "$ref": "extendible-dynamic-ref.json" + }, + { + "$defs": { + "elements": { + "$dynamicAnchor": "elements", + "properties": { + "a": true + }, + "required": ["a"], + "additionalProperties": false + } + } + } + ] + }, + "tests": [ + { + "description": "incorrect parent schema", + "data": { + "a": true + }, + "valid": false + }, + { + "description": "incorrect extended schema", + "data": { + "elements": [ + { "b": 1 } + ] + }, + "valid": false + }, + { + "description": "correct extended schema", + "data": { + "elements": [ + { "a": 1 } + ] + }, + "valid": true + } + ] + }, + { + "description": "$ref and $dynamicAnchor are independent of order - $ref first", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/strict-extendible-allof-ref-first.json", + "allOf": [ + { + "$defs": { + "elements": { + "$dynamicAnchor": "elements", + "properties": { + "a": true + }, + "required": ["a"], + "additionalProperties": false + } + } + }, + { + "$ref": "extendible-dynamic-ref.json" + } + ] + }, + "tests": [ + { + "description": "incorrect parent schema", + "data": { + "a": true + }, + "valid": false + }, + { + "description": "incorrect extended schema", + "data": { + "elements": [ + { "b": 1 } + ] + }, + "valid": false + }, + { + "description": "correct extended schema", + "data": { + "elements": [ + { "a": 1 } + ] + }, + "valid": true + } + ] + }, + { + "description": "$ref to $dynamicRef finds detached $dynamicAnchor", + "schema": { + "$ref": "http://localhost:1234/draft2020-12/detached-dynamicref.json#/$defs/foo" + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "$dynamicRef points to a boolean schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "true": true, + "false": false + }, + "properties": { + "true": { + "$dynamicRef": "#/$defs/true" + }, + "false": { + "$dynamicRef": "#/$defs/false" + } + } + }, + "tests": [ + { + "description": "follow $dynamicRef to a true schema", + "data": { "true": 1 }, + "valid": true + }, + { + "description": "follow $dynamicRef to a false schema", + "data": { "false": 1 }, + "valid": false + } + ] + }, + { + "description": "$dynamicRef skips over intermediate resources - direct reference", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://test.json-schema.org/dynamic-ref-skips-intermediate-resource/main", + "type": "object", + "properties": { + "bar-item": { + "$ref": "item" + } + }, + "$defs": { + "bar": { + "$id": "bar", + "type": "array", + "items": { + "$ref": "item" + }, + "$defs": { + "item": { + "$id": "item", + "type": "object", + "properties": { + "content": { + "$dynamicRef": "#content" + } + }, + "$defs": { + "defaultContent": { + "$dynamicAnchor": "content", + "type": "integer" + } + } + }, + "content": { + "$dynamicAnchor": "content", + "type": "string" + } + } + } + } + }, + "tests": [ + { + "description": "integer property passes", + "data": { "bar-item": { "content": 42 } }, + "valid": true + }, + { + "description": "string property fails", + "data": { "bar-item": { "content": "value" } }, + "valid": false + } + ] + } +] diff --git a/jsonschema/validate.go b/jsonschema/validate.go index 61d0ade..c8cbe30 100644 --- a/jsonschema/validate.go +++ b/jsonschema/validate.go @@ -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, diff --git a/jsonschema/validate_test.go b/jsonschema/validate_test.go index 70c8481..b5cbbc6 100644 --- a/jsonschema/validate_test.go +++ b/jsonschema/validate_test.go @@ -54,11 +54,6 @@ func TestValidate(t *testing.T) { } for _, g := range groups { t.Run(g.Description, func(t *testing.T) { - for s := range g.Schema.all() { - if s.DynamicAnchor != "" || s.DynamicRef != "" { - t.Skip("schema or subschema has unimplemented keywords") - } - } rs, err := g.Schema.Resolve("", loadRemote) if err != nil { t.Fatal(err)