summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJenkins <jenkins@review.openstack.org>2015-04-02 00:09:22 +0000
committerGerrit Code Review <review@openstack.org>2015-04-02 00:09:22 +0000
commit80a8ff330fcb633a8aa2d577dd47a274a9fd9a58 (patch)
tree14cced1f4ad40200b6ffc8b7499159a05710d348
parente57c70b94cb780584d7ed3bc9a3d30e635d73977 (diff)
parent65682fc81a4238bb66c19f290bdfd4b64dc8d9b8 (diff)
downloadglance-80a8ff330fcb633a8aa2d577dd47a274a9fd9a58.tar.gz
Merge "A mixin for jsonpatch requests validation"
-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"])