diff options
author | Jenkins <jenkins@review.openstack.org> | 2015-04-02 00:09:22 +0000 |
---|---|---|
committer | Gerrit Code Review <review@openstack.org> | 2015-04-02 00:09:22 +0000 |
commit | 80a8ff330fcb633a8aa2d577dd47a274a9fd9a58 (patch) | |
tree | 14cced1f4ad40200b6ffc8b7499159a05710d348 | |
parent | e57c70b94cb780584d7ed3bc9a3d30e635d73977 (diff) | |
parent | 65682fc81a4238bb66c19f290bdfd4b64dc8d9b8 (diff) | |
download | glance-80a8ff330fcb633a8aa2d577dd47a274a9fd9a58.tar.gz |
Merge "A mixin for jsonpatch requests validation"
-rw-r--r-- | glance/common/exception.py | 17 | ||||
-rw-r--r-- | glance/common/jsonpatchvalidator.py | 122 | ||||
-rw-r--r-- | glance/tests/unit/test_jsonpatchmixin.py | 71 |
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"]) |