diff --git a/hc/api/decorators.py b/hc/api/decorators.py index ae2bc8bb..cc0777e6 100644 --- a/hc/api/decorators.py +++ b/hc/api/decorators.py @@ -4,7 +4,7 @@ from functools import wraps from django.contrib.auth.models import User from django.http import HttpResponseBadRequest, JsonResponse -from six import string_types +from hc.lib.jsonschema import ValidationError, validate def uuid_or_400(f): @@ -61,21 +61,10 @@ def validate_json(schema): def decorator(f): @wraps(f) def wrapper(request, *args, **kwds): - for key, spec in schema["properties"].items(): - if key not in request.json: - continue - - value = request.json[key] - if spec["type"] == "string": - if not isinstance(value, string_types): - return make_error("%s is not a string" % key) - elif spec["type"] == "number": - if not isinstance(value, int): - return make_error("%s is not a number" % key) - if "minimum" in spec and value < spec["minimum"]: - return make_error("%s is too small" % key) - if "maximum" in spec and value > spec["maximum"]: - return make_error("%s is too large" % key) + try: + validate(request.json, schema) + except ValidationError as e: + return make_error("json validation error: %s" % e) return f(request, *args, **kwds) return wrapper diff --git a/hc/lib/jsonschema.py b/hc/lib/jsonschema.py new file mode 100644 index 00000000..1e5842a4 --- /dev/null +++ b/hc/lib/jsonschema.py @@ -0,0 +1,44 @@ +""" A minimal jsonschema validator. + +Supports only a tiny subset of jsonschema. + +""" + +from six import string_types + + +class ValidationError(Exception): + pass + + +def validate(obj, schema, obj_name="value"): + if schema.get("type") == "string": + if not isinstance(obj, string_types): + raise ValidationError("%s is not a string" % obj_name) + + elif schema.get("type") == "number": + if not isinstance(obj, int): + raise ValidationError("%s is not a number" % obj_name) + if "minimum" in schema and obj < schema["minimum"]: + raise ValidationError("%s is too small" % obj_name) + if "maximum" in schema and obj > schema["maximum"]: + raise ValidationError("%s is too large" % obj_name) + + elif schema.get("type") == "array": + if not isinstance(obj, list): + raise ValidationError("%s is not an array" % obj_name) + + for v in obj: + validate(v, schema["items"], "an item in '%s'" % obj_name) + + elif schema.get("type") == "object": + if not isinstance(obj, dict): + raise ValidationError("%s is not an object" % obj_name) + + for key, spec in schema["properties"].items(): + if key in obj: + validate(obj[key], spec, obj_name=key) + + if "enum" in schema: + if obj not in schema["enum"]: + raise ValidationError("%s has unexpected value" % obj_name) diff --git a/hc/lib/tests/test_jsonschema.py b/hc/lib/tests/test_jsonschema.py new file mode 100644 index 00000000..65e10b5f --- /dev/null +++ b/hc/lib/tests/test_jsonschema.py @@ -0,0 +1,76 @@ +from django.test import TestCase + +from hc.lib.jsonschema import ValidationError, validate + + +class JsonSchemaTestCase(TestCase): + + def test_it_validates_strings(self): + validate("foo", {"type": "string"}) + + def test_it_checks_string_type(self): + with self.assertRaises(ValidationError): + validate(123, {"type": "string"}) + + def test_it_validates_numbers(self): + validate(123, {"type": "number", "minimum": 0, "maximum": 1000}) + + def test_it_checks_int_type(self): + with self.assertRaises(ValidationError): + validate("foo", {"type": "number"}) + + def test_it_checks_min_value(self): + with self.assertRaises(ValidationError): + validate(5, {"type": "number", "minimum": 10}) + + def test_it_checks_max_value(self): + with self.assertRaises(ValidationError): + validate(5, {"type": "number", "maximum": 0}) + + def test_it_validates_objects(self): + validate({"foo": "bar"}, { + "type": "object", + "properties": { + "foo": {"type": "string"} + } + }) + + def test_it_checks_dict_type(self): + with self.assertRaises(ValidationError): + validate("not-object", {"type": "object"}) + + def test_it_validates_objects_properties(self): + with self.assertRaises(ValidationError): + validate({"foo": "bar"}, { + "type": "object", + "properties": { + "foo": {"type": "number"} + } + }) + + def test_it_validates_arrays(self): + validate(["foo", "bar"], { + "type": "array", + "items": {"type": "string"} + }) + + def test_it_validates_array_type(self): + with self.assertRaises(ValidationError): + validate("not-an-array", { + "type": "array", + "items": {"type": "string"} + }) + + def test_it_validates_array_elements(self): + with self.assertRaises(ValidationError): + validate(["foo", "bar"], { + "type": "array", + "items": {"type": "number"} + }) + + def test_it_validates_enum(self): + validate("foo", {"enum": ["foo", "bar"]}) + + def test_it_rejects_a_value_not_in_enum(self): + with self.assertRaises(ValidationError): + validate("baz", {"enum": ["foo", "bar"]})