"""JSON Schema (draft 2020-12) validation with the `jsonschema` package, and the lint of the keyword subset. Non-normative. The spec's schemas are restricted to the keywords below (spec/22-conformance.md) so that every 2020-12 validator reads them alike; `lint` refuses any schema that leaves the subset. """ import json import os from jsonschema import Draft202012Validator from referencing import Registry, Resource KEYWORDS = { "$schema", "$id", "$ref", "$defs", "$comment", "title", "description", "examples", "default", "deprecated", "type", "enum", "const", "properties", "required", "additionalProperties", "patternProperties", "propertyNames", "minProperties", "maxProperties", "dependentRequired", "items", "prefixItems", "minItems", "maxItems", "uniqueItems", "contains", "minLength", "maxLength", "pattern", "format", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "oneOf", "anyOf", "allOf", "not", "if", "then", "else", } FORBIDDEN_PATTERN_TOKENS = ("\\d", "\\w", "\\s", "\\b", "\\D", "\\W", "\\S", "\\B", "(?<", "(?P") class SchemaError(Exception): pass class Store: """The schemas of one folder, linted on load, with a compiled validator per schema.""" def __init__(self, root): self.root = root self.docs = {} self._registry = None self._validators = {} def registry(self): if self._registry is None: names = sorted(f for f in os.listdir(self.root) if f.endswith(".schema.json")) self._registry = Registry().with_resources( [(self.load(f)["$id"], Resource.from_contents(self.load(f))) for f in names]) return self._registry def validator(self, name): if name not in self._validators: self._validators[name] = Draft202012Validator(self.load(name), registry=self.registry()) return self._validators[name] def load(self, name): if name not in self.docs: with open(os.path.join(self.root, name), encoding="utf-8") as f: self.docs[name] = json.load(f) lint(self.docs[name], name) return self.docs[name] def deref(self, base, ref): name, _, frag = ref.partition("#") name = name or base node = self.load(name) if frag: for part in frag.lstrip("/").split("/"): node = node[part.replace("~1", "/").replace("~0", "~")] return name, node def lint(schema, where): """Refuse keywords outside the subset, and regex syntax that differs between dialects.""" if isinstance(schema, dict): for k, v in schema.items(): if k not in KEYWORDS: raise SchemaError("%s: keyword %s is outside the subset" % (where, k)) if k == "pattern" and any(t in v for t in FORBIDDEN_PATTERN_TOKENS): raise SchemaError("%s: pattern %r uses dialect-dependent syntax" % (where, v)) if k in ("properties", "patternProperties", "$defs"): for kk, vv in v.items(): if k == "patternProperties" and any(t in kk for t in FORBIDDEN_PATTERN_TOKENS): raise SchemaError("%s: pattern %r uses dialect-dependent syntax" % (where, kk)) lint(vv, where + "/" + kk) elif k in ("items", "additionalProperties", "propertyNames", "not", "if", "then", "else", "contains"): lint(v, where + "/" + k) elif k in ("oneOf", "anyOf", "allOf", "prefixItems"): for i, s in enumerate(v): lint(s, "%s/%s/%d" % (where, k, i)) def validate(store, name, instance): """Return a list of error strings, `path: message`, in path order; empty when the instance is valid.""" errors = sorted(store.validator(name).iter_errors(instance), key=lambda e: [str(x) for x in e.absolute_path]) return ["%s: %s" % ("/" + "/".join(str(x) for x in e.absolute_path), e.message) for e in errors]