90 lines
3.9 KiB
Python
90 lines
3.9 KiB
Python
"""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]
|