"""A JSON Schema (draft 2020-12) validator for the subset of keywords the spec's schemas use. Non-normative. The spec's schemas are restricted to these keywords (spec/22-conformance.md) so that any 2020-12 validator, and this one, agree; CI also runs the `jsonschema` package when it is installed. """ import json import os import re 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: def __init__(self, root): self.root = root self.docs = {} 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 _type_ok(t, x): if t == "null": return x is None if t == "boolean": return isinstance(x, bool) if t == "integer": return isinstance(x, int) and not isinstance(x, bool) if t == "number": return isinstance(x, (int, float)) and not isinstance(x, bool) if t == "string": return isinstance(x, str) if t == "array": return isinstance(x, list) if t == "object": return isinstance(x, dict) raise SchemaError("unknown type " + t) def _equal(a, b): if isinstance(a, bool) or isinstance(b, bool): return type(a) is type(b) and a == b return a == b def validate(store, name, instance): """Return a list of error strings; empty when the instance is valid.""" schema = store.load(name) errors = [] _v(store, name, schema, instance, "", errors) return errors def _v(store, base, s, x, path, errors): if s is True: return if s is False: errors.append(path + ": not allowed") return if "$ref" in s: b2, target = store.deref(base, s["$ref"]) _v(store, b2, target, x, path, errors) if "type" in s: ts = s["type"] if isinstance(s["type"], list) else [s["type"]] if not any(_type_ok(t, x) for t in ts): errors.append("%s: expected %s" % (path or "/", "/".join(ts))) return if "const" in s and not _equal(s["const"], x): errors.append("%s: expected %r" % (path or "/", s["const"])) if "enum" in s and not any(_equal(e, x) for e in s["enum"]): errors.append("%s: %r is not one of %r" % (path or "/", x, s["enum"])) if isinstance(x, str): if "minLength" in s and len(x) < s["minLength"]: errors.append("%s: shorter than %d" % (path, s["minLength"])) if "maxLength" in s and len(x) > s["maxLength"]: errors.append("%s: longer than %d" % (path, s["maxLength"])) if "pattern" in s and not re.search(s["pattern"], x): errors.append("%s: %r does not match %s" % (path, x, s["pattern"])) if isinstance(x, (int, float)) and not isinstance(x, bool): if "minimum" in s and x < s["minimum"]: errors.append("%s: below %s" % (path, s["minimum"])) if "maximum" in s and x > s["maximum"]: errors.append("%s: above %s" % (path, s["maximum"])) if "exclusiveMinimum" in s and x <= s["exclusiveMinimum"]: errors.append("%s: not above %s" % (path, s["exclusiveMinimum"])) if "exclusiveMaximum" in s and x >= s["exclusiveMaximum"]: errors.append("%s: not below %s" % (path, s["exclusiveMaximum"])) if isinstance(x, list): if "minItems" in s and len(x) < s["minItems"]: errors.append("%s: fewer than %d items" % (path, s["minItems"])) if "maxItems" in s and len(x) > s["maxItems"]: errors.append("%s: more than %d items" % (path, s["maxItems"])) if s.get("uniqueItems"): seen = [json.dumps(i, sort_keys=True) for i in x] if len(seen) != len(set(seen)): errors.append("%s: items are not unique" % path) pre = s.get("prefixItems", []) for i, item in enumerate(x): if i < len(pre): _v(store, base, pre[i], item, "%s/%d" % (path, i), errors) elif "items" in s: _v(store, base, s["items"], item, "%s/%d" % (path, i), errors) if "contains" in s: if not any(not _sub(store, base, s["contains"], i) for i in x): errors.append("%s: no item matches `contains`" % path) if isinstance(x, dict): if "minProperties" in s and len(x) < s["minProperties"]: errors.append("%s: fewer than %d properties" % (path, s["minProperties"])) if "maxProperties" in s and len(x) > s["maxProperties"]: errors.append("%s: more than %d properties" % (path, s["maxProperties"])) for r in s.get("required", []): if r not in x: errors.append("%s: missing %s" % (path or "/", r)) for k, deps in s.get("dependentRequired", {}).items(): if k in x: for d in deps: if d not in x: errors.append("%s: %s requires %s" % (path or "/", k, d)) props = s.get("properties", {}) pprops = s.get("patternProperties", {}) for k, v in x.items(): matched = False if k in props: matched = True _v(store, base, props[k], v, path + "/" + k, errors) for pat, ps in pprops.items(): if re.search(pat, k): matched = True _v(store, base, ps, v, path + "/" + k, errors) if not matched and "additionalProperties" in s: ap = s["additionalProperties"] if ap is False: errors.append("%s: unexpected property %s" % (path or "/", k)) else: _v(store, base, ap, v, path + "/" + k, errors) if "propertyNames" in s: _v(store, base, s["propertyNames"], k, path + "/" + k, errors) for sub in s.get("allOf", []): _v(store, base, sub, x, path, errors) if "anyOf" in s: if all(_sub(store, base, sub, x) for sub in s["anyOf"]): errors.append("%s: matches none of anyOf" % (path or "/")) if "oneOf" in s: ok = [sub for sub in s["oneOf"] if not _sub(store, base, sub, x)] if len(ok) != 1: detail = "" if not ok: best = min((_sub(store, base, sub, x) for sub in s["oneOf"]), key=len) detail = " (closest: %s)" % "; ".join(best[:3]) errors.append("%s: matches %d of oneOf%s" % (path or "/", len(ok), detail)) if "not" in s and not _sub(store, base, s["not"], x): errors.append("%s: matches `not`" % (path or "/")) if "if" in s: if not _sub(store, base, s["if"], x): if "then" in s: _v(store, base, s["then"], x, path, errors) elif "else" in s: _v(store, base, s["else"], x, path, errors) def _sub(store, base, s, x): e = [] _v(store, base, s, x, "", e) return e