diff --git a/draft.go b/draft.go index 80936a7..4dd4b14 100644 --- a/draft.go +++ b/draft.go @@ -2,30 +2,66 @@ package jsonschema import ( "fmt" - "strconv" "strings" ) -type position uint +type Position uint const ( - posSelf position = 1 << iota - posProp - posItem + PosProp Position = 0 + PosItem Position = 1 ) -// TODO: subschemas for propertyDependencies keyword -// cannot be captured using current implementation +type SchemaPosition []Position + +func (sp SchemaPosition) collect(v any, ptr jsonPointer, target map[jsonPointer]any) { + if len(sp) == 0 { + target[ptr] = v + return + } + p, sp := sp[0], sp[1:] + switch p { + case PosProp: + if obj, ok := v.(map[string]any); ok { + for pname, pvalue := range obj { + ptr := ptr.append(pname) + sp.collect(pvalue, ptr, target) + } + } + case PosItem: + if arr, ok := v.([]any); ok { + for i, item := range arr { + ptr := ptr.append(fmt.Sprint(i)) + sp.collect(item, ptr, target) + } + } + } +} + +type SubSchemas map[string][]SchemaPosition + +func (ss SubSchemas) collect(obj map[string]any, ptr jsonPointer, target map[jsonPointer]any) { + for kw, spp := range ss { + v, ok := obj[kw] + if !ok { + continue + } + ptr := ptr.append(kw) + for _, sp := range spp { + sp.collect(v, ptr, target) + } + } +} type Draft struct { version int url string sch *Schema - id string // property name used to represent id - subschemas map[string]position // locations of subschemas - vocabPrefix string // prefix used for vocabulary - allVocabs map[string]*Schema // names of supported vocabs with its schemas - defaultVocabs []string // names of default vocabs + id string // property name used to represent id + subschemas SubSchemas // locations of subschemas + vocabPrefix string // prefix used for vocabulary + allVocabs map[string]*Schema // names of supported vocabs with its schemas + defaultVocabs []string // names of default vocabs } var ( @@ -33,21 +69,21 @@ var ( version: 4, url: "http://json-schema.org/draft-04/schema", id: "id", - subschemas: map[string]position{ + subschemas: map[string][]SchemaPosition{ // type agonistic - "definitions": posProp, - "not": posSelf, - "allOf": posItem, - "anyOf": posItem, - "oneOf": posItem, + "definitions": {{PosProp}}, + "not": {{}}, + "allOf": {{PosItem}}, + "anyOf": {{PosItem}}, + "oneOf": {{PosItem}}, // object - "properties": posProp, - "additionalProperties": posSelf, - "patternProperties": posProp, + "properties": {{PosProp}}, + "additionalProperties": {{}}, + "patternProperties": {{PosProp}}, // array - "items": posSelf | posItem, - "additionalItems": posSelf, - "dependencies": posProp, + "items": {{}, {PosItem}}, + "additionalItems": {{}}, + "dependencies": {{PosProp}}, }, vocabPrefix: "", allVocabs: map[string]*Schema{}, @@ -58,9 +94,9 @@ var ( version: 6, url: "http://json-schema.org/draft-06/schema", id: "$id", - subschemas: joinMaps(Draft4.subschemas, map[string]position{ - "propertyNames": posSelf, - "contains": posSelf, + subschemas: joinMaps(Draft4.subschemas, map[string][]SchemaPosition{ + "propertyNames": {{}}, + "contains": {{}}, }), vocabPrefix: "", allVocabs: map[string]*Schema{}, @@ -71,10 +107,10 @@ var ( version: 7, url: "http://json-schema.org/draft-07/schema", id: "$id", - subschemas: joinMaps(Draft6.subschemas, map[string]position{ - "if": posSelf, - "then": posSelf, - "else": posSelf, + subschemas: joinMaps(Draft6.subschemas, map[string][]SchemaPosition{ + "if": {{}}, + "then": {{}}, + "else": {{}}, }), vocabPrefix: "", allVocabs: map[string]*Schema{}, @@ -85,12 +121,12 @@ var ( version: 2019, url: "https://json-schema.org/draft/2019-09/schema", id: "$id", - subschemas: joinMaps(Draft7.subschemas, map[string]position{ - "$defs": posProp, - "dependentSchemas": posProp, - "unevaluatedProperties": posSelf, - "unevaluatedItems": posSelf, - "contentSchema": posSelf, + subschemas: joinMaps(Draft7.subschemas, map[string][]SchemaPosition{ + "$defs": {{PosProp}}, + "dependentSchemas": {{PosProp}}, + "unevaluatedProperties": {{}}, + "unevaluatedItems": {{}}, + "contentSchema": {{}}, }), vocabPrefix: "https://json-schema.org/draft/2019-09/vocab/", allVocabs: map[string]*Schema{ @@ -108,8 +144,8 @@ var ( version: 2020, url: "https://json-schema.org/draft/2020-12/schema", id: "$id", - subschemas: joinMaps(Draft2019.subschemas, map[string]position{ - "prefixItems": posItem, + subschemas: joinMaps(Draft2019.subschemas, map[string][]SchemaPosition{ + "prefixItems": {{PosItem}}, }), vocabPrefix: "https://json-schema.org/draft/2020-12/vocab/", allVocabs: map[string]*Schema{ @@ -182,200 +218,6 @@ func (d *Draft) getID(obj map[string]any) string { return id } -func (d *Draft) collectAnchors(sch any, schPtr jsonPointer, res *resource, url url) error { - obj, ok := sch.(map[string]any) - if !ok { - return nil - } - - addAnchor := func(anchor anchor) error { - ptr1, ok := res.anchors[anchor] - if ok { - if ptr1 == schPtr { - // anchor with same root_ptr already exists - return nil - } - return &DuplicateAnchorError{ - string(anchor), url.String(), string(ptr1), string(schPtr), - } - } - res.anchors[anchor] = schPtr - return nil - } - - if d.version < 2019 { - if _, ok := obj["$ref"]; ok { - // All other properties in a "$ref" object MUST be ignored - return nil - } - // anchor is specified in id - if id, ok := strVal(obj, d.id); ok { - _, frag, err := splitFragment(id) - if err != nil { - loc := urlPtr{url, schPtr} - return &ParseAnchorError{loc.String()} - } - if anchor, ok := frag.convert().(anchor); ok { - if err := addAnchor(anchor); err != nil { - return err - } - } - } - } - if d.version >= 2019 { - if s, ok := strVal(obj, "$anchor"); ok { - if err := addAnchor(anchor(s)); err != nil { - return err - } - } - } - if d.version >= 2020 { - if s, ok := strVal(obj, "$dynamicAnchor"); ok { - if err := addAnchor(anchor(s)); err != nil { - return err - } - res.dynamicAnchors = append(res.dynamicAnchors, anchor(s)) - } - } - - return nil -} - -func (d *Draft) collectResources(sch any, base url, schPtr jsonPointer, url url, resources map[jsonPointer]*resource) error { - if _, ok := resources[schPtr]; ok { - // resources are already collected - return nil - } - if _, ok := sch.(bool); ok { - if schPtr.isEmpty() { - // root resource - resources[schPtr] = newResource(schPtr, base) - } - return nil - } - obj, ok := sch.(map[string]any) - if !ok { - return nil - } - - if sch, ok := obj["$schema"]; ok { - if sch, ok := sch.(string); ok && sch != "" { - if got := draftFromURL(sch); got != nil && got != d { - loc := urlPtr{url, schPtr} - return &MetaSchemaMismatchError{loc.String()} - } - } - } - - var res *resource - if id := d.getID(obj); id != "" { - uf, err := base.join(id) - if err != nil { - loc := urlPtr{url, schPtr} - return &ParseIDError{loc.String()} - } - base = uf.url - res = newResource(schPtr, base) - } else if schPtr.isEmpty() { - // root resource - res = newResource(schPtr, base) - } - - if res != nil { - for _, res := range resources { - if res.id == base { - return &DuplicateIDError{base.String(), url.String(), string(schPtr), string(res.ptr)} - } - } - resources[schPtr] = res - } - - // collect anchors into base resource - for _, res := range resources { - if res.id == base { - // found base resource - if err := d.collectAnchors(sch, schPtr, res, url); err != nil { - return err - } - break - } - } - - for kw, pos := range d.subschemas { - v, ok := obj[kw] - if !ok { - continue - } - if pos&posSelf != 0 { - ptr := schPtr.append(kw) - if err := d.collectResources(v, base, ptr, url, resources); err != nil { - return err - } - } - if pos&posItem != 0 { - if arr, ok := v.([]any); ok { - for i, item := range arr { - ptr := schPtr.append2(kw, fmt.Sprint(i)) - if err := d.collectResources(item, base, ptr, url, resources); err != nil { - return err - } - } - } - } - if pos&posProp != 0 { - if obj, ok := v.(map[string]any); ok { - for pname, pvalue := range obj { - ptr := schPtr.append2(kw, pname) - if err := d.collectResources(pvalue, base, ptr, url, resources); err != nil { - return err - } - } - } - } - } - - return nil -} - -func (d *Draft) isSubschema(ptr string) bool { - if ptr == "" { - return true - } - - split := func(ptr string) (string, string) { - ptr = ptr[1:] // rm `/` prefix - if slash := strings.IndexByte(ptr, '/'); slash != -1 { - return ptr[:slash], ptr[slash:] - } else { - return ptr, "" - } - } - - tok, ptr := split(ptr) - if pos, ok := d.subschemas[tok]; ok { - if pos&posSelf != 0 && d.isSubschema(ptr) { - return true - } - if ptr != "" { - if pos&posProp != 0 { - _, ptr := split(ptr) - if d.isSubschema(ptr) { - return true - } - } - if pos&posItem != 0 { - tok, ptr := split(ptr) - _, err := strconv.Atoi(tok) - if err == nil && d.isSubschema(ptr) { - return true - } - } - } - } - - return false -} - func (d *Draft) validate(up urlPtr, v any, regexpEngine RegexpEngine) error { err := d.sch.validate(v, regexpEngine) if err != nil { @@ -432,8 +274,8 @@ func (e *DuplicateAnchorError) Error() string { // -- -func joinMaps(m1 map[string]position, m2 map[string]position) map[string]position { - m := make(map[string]position) +func joinMaps(m1 map[string][]SchemaPosition, m2 map[string][]SchemaPosition) map[string][]SchemaPosition { + m := make(map[string][]SchemaPosition) for k, v := range m1 { m[k] = v } diff --git a/draft_test.go b/draft_test.go index a3cf7b3..e6b149f 100644 --- a/draft_test.go +++ b/draft_test.go @@ -70,11 +70,19 @@ func TestDraft_collectIds(t *testing.T) { "/definitions/s4": "http://e.com/def", // id with fragments } - resources := make(map[jsonPointer]*resource) - if err := Draft4.collectResources(doc, u, jsonPointer(""), u, resources); err != nil { + r := root{ + url: url(u), + doc: doc, + draft: Draft4, + resources: map[jsonPointer]*resource{}, + metaVocabs: nil, + subschemasProcessed: map[jsonPointer]struct{}{}, + } + if err := r.collectResources(doc, u, jsonPointer("")); err != nil { t.Fatal(err) } + resources := r.resources got := make(map[string]string) for ptr, res := range resources { got[string(ptr)] = res.id.String() @@ -110,11 +118,19 @@ func TestDraft_collectAnchors(t *testing.T) { t.Fatal(err) } - resources := make(map[jsonPointer]*resource) - if err := Draft2020.collectResources(doc, u, jsonPointer(""), u, resources); err != nil { + r := root{ + url: url(u), + doc: doc, + draft: Draft2020, + resources: map[jsonPointer]*resource{}, + metaVocabs: nil, + subschemasProcessed: map[jsonPointer]struct{}{}, + } + if err := r.collectResources(doc, u, jsonPointer("")); err != nil { t.Fatal(err) } + resources := r.resources res, ok := resources[""] if !ok { t.Fatal("root resource is not collected") @@ -148,19 +164,3 @@ func TestDraft_collectAnchors(t *testing.T) { t.Fatalf("anchors for /$defs/s2/items/1:\n got: %v\nwant: %v", res.anchors, want) } } - -func TestDraft_isSubschema(t *testing.T) { - tests := []struct { - input string - want bool - }{ - {"/allOf/0", true}, - {"/allOf/$defs", false}, - } - for _, test := range tests { - got := Draft2020.isSubschema(test.input) - if got != test.want { - t.Fatalf("Draft2020.isSubschema(%q): got %v, want %v", test.input, got, test.want) - } - } -} diff --git a/root.go b/root.go index 405d89a..4b8a7f4 100644 --- a/root.go +++ b/root.go @@ -12,6 +12,8 @@ type root struct { draft *Draft resources map[jsonPointer]*resource metaVocabs []string // nil means use draft + + subschemasProcessed map[jsonPointer]struct{} } func (r *root) hasVocab(name string) bool { @@ -127,26 +129,170 @@ func (r *root) resolve(uf urlFrag) (*urlPtr, error) { return &up, err } +func (r *root) collectResources(sch any, base url, schPtr jsonPointer) error { + if _, ok := r.subschemasProcessed[schPtr]; ok { + return nil + } + if err := r._collectResources(sch, base, schPtr); err != nil { + return err + } + r.subschemasProcessed[schPtr] = struct{}{} + return nil +} + +func (r *root) _collectResources(sch any, base url, schPtr jsonPointer) error { + if _, ok := sch.(bool); ok { + if schPtr.isEmpty() { + // root resource + r.resources[schPtr] = newResource(schPtr, base) + } + return nil + } + obj, ok := sch.(map[string]any) + if !ok { + return nil + } + + if sch, ok := obj["$schema"]; ok { + if sch, ok := sch.(string); ok && sch != "" { + if got := draftFromURL(sch); got != nil && got != r.draft { + loc := urlPtr{r.url, schPtr} + return &MetaSchemaMismatchError{loc.String()} + } + } + } + + var res *resource + if id := r.draft.getID(obj); id != "" { + uf, err := base.join(id) + if err != nil { + loc := urlPtr{r.url, schPtr} + return &ParseIDError{loc.String()} + } + base = uf.url + res = newResource(schPtr, base) + } else if schPtr.isEmpty() { + // root resource + res = newResource(schPtr, base) + } + + if res != nil { + found := false + for _, res := range r.resources { + if res.id == base { + found = true + if res.ptr != schPtr { + return &DuplicateIDError{base.String(), r.url.String(), string(schPtr), string(res.ptr)} + } + } + } + if !found { + r.resources[schPtr] = res + } + } + + // collect anchors into base resource + for _, res := range r.resources { + if res.id == base { + // found base resource + if err := r.collectAnchors(sch, schPtr, res); err != nil { + return err + } + break + } + } + + // process subschemas + subschemas := map[jsonPointer]any{} + r.draft.subschemas.collect(obj, schPtr, subschemas) + for ptr, v := range subschemas { + if err := r.collectResources(v, base, ptr); err != nil { + return err + } + } + + return nil +} + func (r *root) addSubschema(ptr jsonPointer) error { v, err := (&urlPtr{r.url, ptr}).lookup(r.doc) if err != nil { return err } baseURL := r.baseURL(ptr) - if err := r.draft.collectResources(v, baseURL, ptr, r.url, r.resources); err != nil { + if err := r.collectResources(v, baseURL, ptr); err != nil { return err } // collect anchors if _, ok := r.resources[ptr]; !ok { res := r.resource(ptr) - if err := r.draft.collectAnchors(v, ptr, res, r.url); err != nil { + if err := r.collectAnchors(v, ptr, res); err != nil { return err } } return nil } +func (r *root) collectAnchors(sch any, schPtr jsonPointer, res *resource) error { + obj, ok := sch.(map[string]any) + if !ok { + return nil + } + + addAnchor := func(anchor anchor) error { + ptr1, ok := res.anchors[anchor] + if ok { + if ptr1 == schPtr { + // anchor with same root_ptr already exists + return nil + } + return &DuplicateAnchorError{ + string(anchor), r.url.String(), string(ptr1), string(schPtr), + } + } + res.anchors[anchor] = schPtr + return nil + } + + if r.draft.version < 2019 { + if _, ok := obj["$ref"]; ok { + // All other properties in a "$ref" object MUST be ignored + return nil + } + // anchor is specified in id + if id, ok := strVal(obj, r.draft.id); ok { + _, frag, err := splitFragment(id) + if err != nil { + loc := urlPtr{r.url, schPtr} + return &ParseAnchorError{loc.String()} + } + if anchor, ok := frag.convert().(anchor); ok { + if err := addAnchor(anchor); err != nil { + return err + } + } + } + } + if r.draft.version >= 2019 { + if s, ok := strVal(obj, "$anchor"); ok { + if err := addAnchor(anchor(s)); err != nil { + return err + } + } + } + if r.draft.version >= 2020 { + if s, ok := strVal(obj, "$dynamicAnchor"); ok { + if err := addAnchor(anchor(s)); err != nil { + return err + } + res.dynamicAnchors = append(res.dynamicAnchors, anchor(s)) + } + } + + return nil +} + func (r *root) validate(ptr jsonPointer, v any, regexpEngine RegexpEngine) error { up := urlPtr{r.url, ptr} if r.metaVocabs == nil { diff --git a/roots.go b/roots.go index 49bfbd9..97c7dc3 100644 --- a/roots.go +++ b/roots.go @@ -100,18 +100,16 @@ func (rr *roots) addRoot(u url, doc any, cycle map[url]struct{}) (*root, error) if err != nil { return nil, err } - - resources := map[jsonPointer]*resource{} - if err := meta.draft.collectResources(doc, u, "", u, resources); err != nil { - return nil, err - } - r := &root{ - url: u, - doc: doc, - draft: meta.draft, - resources: resources, - metaVocabs: meta.vocabs, + url: u, + doc: doc, + draft: meta.draft, + resources: map[jsonPointer]*resource{}, + metaVocabs: meta.vocabs, + subschemasProcessed: map[jsonPointer]struct{}{}, + } + if err := r.collectResources(doc, u, ""); err != nil { + return nil, err } if !strings.HasPrefix(u.String(), "http://json-schema.org/") && !strings.HasPrefix(u.String(), "https://json-schema.org/") { @@ -137,7 +135,7 @@ func (rr *roots) ensureSubschema(up urlPtr) error { if err != nil { return err } - if r.draft.isSubschema(string(up.ptr)) { + if _, ok := r.subschemasProcessed[up.ptr]; ok { return nil } v, err := up.lookup(r.doc)