summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorInessa Vasilevskaya <ivasilevskaya@mirantis.com>2015-01-20 18:52:01 +0400
committerMike Fedosin <mfedosin@mirantis.com>2015-03-30 20:51:10 +0300
commit65682fc81a4238bb66c19f290bdfd4b64dc8d9b8 (patch)
treeb855e6fb37352435157808a0627d0e37fa376b68
parent8e4b2dad0b71f060b6b0c5114cb76283c4ee367e (diff)
downloadglance-65682fc81a4238bb66c19f290bdfd4b64dc8d9b8.tar.gz
A mixin for jsonpatch requests validation
A JSONPatch-enabled API requires validation logic which will check the correctness of incoming request body according to schema. A mixin is added which will do such validations. Such validation logic may be useful not only for Artifacts API but also for regular v2 images API, as it also uses JSONPatch notation in its update calls. So this mixin may be used in future in Images API as well. Implements-blueprint: artifact-repository Change-Id: I1b7b72bcc0a0455803a4e99dd17a9732b0dd7a8d
-rw-r--r--glance/common/exception.py17
-rw-r--r--glance/common/jsonpatchvalidator.py122
-rw-r--r--glance/tests/unit/test_jsonpatchmixin.py71
3 files changed, 210 insertions, 0 deletions
diff --git a/glance/common/exception.py b/glance/common/exception.py
index 2e7350e28..c56d4215c 100644
--- a/glance/common/exception.py
+++ b/glance/common/exception.py
@@ -537,3 +537,20 @@ class UnknownArtifactType(NotFound):
class ArtifactInvalidStateTransition(Invalid):
message = _("Artifact state cannot be changed from %(curr)s to %(to)s")
+
+
+class JsonPatchException(GlanceException):
+ message = _("Invalid jsonpatch request")
+
+
+class InvalidJsonPatchBody(JsonPatchException):
+ message = _("The provided body %(body)s is invalid "
+ "under given schema: %(schema)s")
+
+
+class InvalidJsonPatchPath(JsonPatchException):
+ message = _("The provided path '%(path)s' is invalid: %(explanation)s")
+
+ def __init__(self, message=None, *args, **kwargs):
+ self.explanation = kwargs.get("explanation")
+ super(InvalidJsonPatchPath, self).__init__(message, *args, **kwargs)
diff --git a/glance/common/jsonpatchvalidator.py b/glance/common/jsonpatchvalidator.py
new file mode 100644
index 000000000..774cef220
--- /dev/null
+++ b/glance/common/jsonpatchvalidator.py
@@ -0,0 +1,122 @@
+# Copyright 2015 OpenStack Foundation.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+"""
+A mixin that validates the given body for jsonpatch-compatibility.
+The methods supported are limited to listed in METHODS_ALLOWED
+"""
+
+import re
+
+import jsonschema
+
+import glance.common.exception as exc
+from glance.openstack.common._i18n import _
+
+
+class JsonPatchValidatorMixin(object):
+ # a list of allowed methods allowed according to RFC 6902
+ ALLOWED = ["replace", "test", "remove", "add", "copy"]
+ PATH_REGEX_COMPILED = re.compile("^/[^/]+(/[^/]+)*$")
+
+ def __init__(self, methods_allowed=["replace", "remove"]):
+ self.schema = self._gen_schema(methods_allowed)
+ self.methods_allowed = [m for m in methods_allowed
+ if m in self.ALLOWED]
+
+ @staticmethod
+ def _gen_schema(methods_allowed):
+ """
+ Generates a jsonschema for jsonpatch request based on methods_allowed
+ """
+ # op replace needs no 'value' param, so needs a special schema if
+ # present in methods_allowed
+ basic_schema = {
+ "type": "array",
+ "items": {"properties": {"op": {"type": "string",
+ "enum": methods_allowed},
+ "path": {"type": "string"},
+ "value": {"type": ["string",
+ "object",
+ "integer",
+ "array",
+ "boolean"]}
+ },
+ "required": ["op", "path", "value"],
+ "type": "object"},
+ "$schema": "http://json-schema.org/draft-04/schema#"
+ }
+ if "remove" in methods_allowed:
+ methods_allowed.remove("remove")
+ no_remove_op_schema = {
+ "type": "object",
+ "properties": {
+ "op": {"type": "string", "enum": methods_allowed},
+ "path": {"type": "string"},
+ "value": {"type": ["string", "object",
+ "integer", "array", "boolean"]}
+ },
+ "required": ["op", "path", "value"]}
+ op_remove_only_schema = {
+ "type": "object",
+ "properties": {
+ "op": {"type": "string", "enum": ["remove"]},
+ "path": {"type": "string"}
+ },
+ "required": ["op", "path"]}
+
+ basic_schema = {
+ "type": "array",
+ "items": {
+ "oneOf": [no_remove_op_schema, op_remove_only_schema]},
+ "$schema": "http://json-schema.org/draft-04/schema#"
+ }
+ return basic_schema
+
+ def validate_body(self, body):
+ try:
+ jsonschema.validate(body, self.schema)
+ # now make sure everything is ok with path
+ return [{"path": self._decode_json_pointer(e["path"]),
+ "value": e.get("value", None),
+ "op": e["op"]} for e in body]
+ except jsonschema.ValidationError:
+ raise exc.InvalidJsonPatchBody(body=body, schema=self.schema)
+
+ def _check_for_path_errors(self, pointer):
+ if not re.match(self.PATH_REGEX_COMPILED, pointer):
+ msg = _("Json path should start with a '/', "
+ "end with no '/', no 2 subsequent '/' are allowed.")
+ raise exc.InvalidJsonPatchPath(path=pointer, explanation=msg)
+ if re.search('~[^01]', pointer) or pointer.endswith('~'):
+ msg = _("Pointer contains '~' which is not part of"
+ " a recognized escape sequence [~0, ~1].")
+ raise exc.InvalidJsonPatchPath(path=pointer, explanation=msg)
+
+ def _decode_json_pointer(self, pointer):
+ """Parses a json pointer. Returns a pointer as a string.
+
+ Json Pointers are defined in
+ http://tools.ietf.org/html/draft-pbryan-zyp-json-pointer .
+ The pointers use '/' for separation between object attributes.
+ A '/' character in an attribute name is encoded as "~1" and
+ a '~' character is encoded as "~0".
+ """
+ self._check_for_path_errors(pointer)
+ ret = []
+ for part in pointer.lstrip('/').split('/'):
+ ret.append(part.replace('~1', '/').replace('~0', '~').strip())
+ return '/'.join(ret)
diff --git a/glance/tests/unit/test_jsonpatchmixin.py b/glance/tests/unit/test_jsonpatchmixin.py
new file mode 100644
index 000000000..0b4706a7e
--- /dev/null
+++ b/glance/tests/unit/test_jsonpatchmixin.py
@@ -0,0 +1,71 @@
+# Copyright (c) 2015 Mirantis, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+import glance.common.exception as exc
+import glance.common.jsonpatchvalidator as jpv
+import glance.tests.utils as utils
+
+
+class TestValidator(jpv.JsonPatchValidatorMixin):
+ def __init__(self, methods_allowed=["replace", "add"]):
+ super(TestValidator, self).__init__(methods_allowed)
+
+
+class TestJsonPatchMixin(utils.BaseTestCase):
+ def test_body_validation(self):
+ validator = TestValidator()
+ validator.validate_body(
+ [{"op": "replace", "path": "/param", "value": "ok"}])
+ # invalid if not a list of [{"op": "", "path": "", "value": ""}]
+ # is passed
+ self.assertRaises(exc.JsonPatchException, validator.validate_body,
+ {"op": "replace", "path": "/me",
+ "value": "should be a list"})
+
+ def test_value_validation(self):
+ # a string, a list and a dict are valid value types
+ validator = TestValidator()
+ validator.validate_body(
+ [{"op": "replace", "path": "/param", "value": "ok string"}])
+ validator.validate_body(
+ [{"op": "replace", "path": "/param",
+ "value": ["ok list", "really ok"]}])
+ validator.validate_body(
+ [{"op": "replace", "path": "/param", "value": {"ok": "dict"}}])
+
+ def test_op_validation(self):
+ validator = TestValidator(methods_allowed=["replace", "add", "copy"])
+ validator.validate_body(
+ [{"op": "copy", "path": "/param", "value": "ok"},
+ {"op": "replace", "path": "/param/1", "value": "ok"}])
+ self.assertRaises(
+ exc.JsonPatchException, validator.validate_body,
+ [{"op": "test", "path": "/param", "value": "not allowed"}])
+ self.assertRaises(exc.JsonPatchException, validator.validate_body,
+ [{"op": "nosuchmethodatall", "path": "/param",
+ "value": "no way"}])
+
+ def test_path_validation(self):
+ validator = TestValidator()
+ bad_body_part = {"op": "add", "value": "bad path"}
+ for bad_path in ["/param/", "param", "//param", "/param~2", "/param~"]:
+ bad_body_part["path"] = bad_path
+ bad_body = [bad_body_part]
+ self.assertRaises(exc.JsonPatchException,
+ validator.validate_body, bad_body)
+ ok_body = [{"op": "add", "value": "some value",
+ "path": "/param~1/param~0"}]
+ body = validator.validate_body(ok_body)[0]
+ self.assertEqual("param//param~", body["path"])