From 84f83a3d7082366180ee443f1d2f05f6d925f8b0 Mon Sep 17 00:00:00 2001 From: chenying Date: Thu, 30 Nov 2017 17:23:54 +0800 Subject: [PATCH] jsonschema validation: base schema framework and plans API This patch adds a base jsonschema framework which will be used by other subsequent patches for adding jsonschema validation support for Karbor V1 APIs. This patch adds jsonschema validation for Karbor Plans API. This patch follows the Cinder-Schema-framework: https://github.com/openstack/cinder/tree/master/cinder/api/validation Change-Id: I5a552f33608a924b0a410fdf1b83b7f5c4759aae Partial-Implements: bp karbor-json-schema-validation --- karbor/api/schemas/__init__.py | 0 karbor/api/schemas/plans.py | 59 +++ karbor/api/v1/plans.py | 4 + karbor/api/validation/__init__.py | 43 ++ karbor/api/validation/parameter_types.py | 165 ++++++ karbor/api/validation/validators.py | 214 ++++++++ karbor/exception.py | 8 + karbor/tests/unit/api/test_api_validation.py | 500 ++++++++++++++++++ karbor/tests/unit/api/v1/test_plans.py | 66 ++- .../unit/api/v1/test_scheduled_operation.py | 2 +- requirements.txt | 1 + 11 files changed, 1025 insertions(+), 37 deletions(-) create mode 100644 karbor/api/schemas/__init__.py create mode 100644 karbor/api/schemas/plans.py create mode 100644 karbor/api/validation/__init__.py create mode 100644 karbor/api/validation/parameter_types.py create mode 100644 karbor/api/validation/validators.py create mode 100644 karbor/tests/unit/api/test_api_validation.py diff --git a/karbor/api/schemas/__init__.py b/karbor/api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/karbor/api/schemas/plans.py b/karbor/api/schemas/plans.py new file mode 100644 index 00000000..d8a0088b --- /dev/null +++ b/karbor/api/schemas/plans.py @@ -0,0 +1,59 @@ +# 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. + +""" +Schema for Karbor V1 Plans API. + +""" + +from karbor.api.validation import parameter_types + + +create = { + 'type': 'object', + 'properties': { + 'type': 'object', + 'plan': { + 'type': 'object', + 'properties': { + 'name': parameter_types.name, + 'description': parameter_types.description, + 'provider_id': parameter_types.uuid, + 'parameters': parameter_types.parameters, + 'resources': parameter_types.resources, + }, + 'required': ['provider_id', 'parameters'], + 'additionalProperties': False, + }, + }, + 'required': ['plan'], + 'additionalProperties': False, +} + +update = { + 'type': 'object', + 'properties': { + 'type': 'object', + 'plan': { + 'type': 'object', + 'properties': { + 'name': parameter_types.name, + 'status': {'type': ['string', 'null']}, + 'resources': parameter_types.resources, + }, + 'required': [], + 'additionalProperties': False, + }, + }, + 'required': ['plan'], + 'additionalProperties': False, +} diff --git a/karbor/api/v1/plans.py b/karbor/api/v1/plans.py index b38efd34..983805e3 100644 --- a/karbor/api/v1/plans.py +++ b/karbor/api/v1/plans.py @@ -22,6 +22,8 @@ from webob import exc from karbor.api import common from karbor.api.openstack import wsgi +from karbor.api.schemas import plans as plan_schema +from karbor.api import validation from karbor.common import constants from karbor import exception from karbor.i18n import _ @@ -248,6 +250,7 @@ class PlansController(wsgi.Controller): """Return plan search options allowed by non-admin.""" return CONF.query_plan_filters + @validation.schema(plan_schema.create) def create(self, req, body): """Creates a new plan.""" if not self.is_valid_body(body, 'plan'): @@ -319,6 +322,7 @@ class PlansController(wsgi.Controller): return retval + @validation.schema(plan_schema.update) def update(self, req, id, body): """Update a plan.""" context = req.environ['karbor.context'] diff --git a/karbor/api/validation/__init__.py b/karbor/api/validation/__init__.py new file mode 100644 index 00000000..223d6185 --- /dev/null +++ b/karbor/api/validation/__init__.py @@ -0,0 +1,43 @@ +# 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. + +""" +Request Body validating middleware. + +""" + +import functools + +from karbor.api.validation import validators + + +def schema(request_body_schema): + """Register a schema to validate request body. + + Registered schema will be used for validating request body just before + API method executing. + + :param dict request_body_schema: a schema to validate request body + + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + schema_validator = validators._SchemaValidator( + request_body_schema) + schema_validator.validate(kwargs['body']) + + return func(*args, **kwargs) + return wrapper + + return add_validator diff --git a/karbor/api/validation/parameter_types.py b/karbor/api/validation/parameter_types.py new file mode 100644 index 00000000..732d6915 --- /dev/null +++ b/karbor/api/validation/parameter_types.py @@ -0,0 +1,165 @@ +# 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. + +""" +Common parameter types for validating request Body. + +""" + +import re +import unicodedata + +import six + + +def _is_printable(char): + """determine if a unicode code point is printable. + + This checks if the character is either "other" (mostly control + codes), or a non-horizontal space. All characters that don't match + those criteria are considered printable; that is: letters; + combining marks; numbers; punctuation; symbols; (horizontal) space + separators. + """ + category = unicodedata.category(char) + return (not category.startswith("C") and + (not category.startswith("Z") or category == "Zs")) + + +def _get_all_chars(): + for i in range(0xFFFF): + yield six.unichr(i) + + +# build a regex that matches all printable characters. This allows +# spaces in the middle of the name. Also note that the regexp below +# deliberately allows the empty string. This is so only the constraint +# which enforces a minimum length for the name is triggered when an +# empty string is tested. Otherwise it is not deterministic which +# constraint fails and this causes issues for some unittests when +# PYTHONHASHSEED is set randomly. + +def _build_regex_range(ws=True, invert=False, exclude=None): + """Build a range regex for a set of characters in utf8. + + This builds a valid range regex for characters in utf8 by + iterating the entire space and building up a set of x-y ranges for + all the characters we find which are valid. + + :param ws: should we include whitespace in this range. + :param exclude: any characters we want to exclude + :param invert: invert the logic + + The inversion is useful when we want to generate a set of ranges + which is everything that's not a certain class. For instance, + produce all all the non printable characters as a set of ranges. + """ + if exclude is None: + exclude = [] + regex = "" + # are we currently in a range + in_range = False + # last character we found, for closing ranges + last = None + # last character we added to the regex, this lets us know that we + # already have B in the range, which means we don't need to close + # it out with B-B. While the later seems to work, it's kind of bad form. + last_added = None + + def valid_char(char): + if char in exclude: + result = False + elif ws: + result = _is_printable(char) + else: + # Zs is the unicode class for space characters, of which + # there are about 10 in this range. + result = (_is_printable(char) and + unicodedata.category(char) != "Zs") + if invert is True: + return not result + return result + + # iterate through the entire character range. in_ + for c in _get_all_chars(): + if valid_char(c): + if not in_range: + regex += re.escape(c) + last_added = c + in_range = True + else: + if in_range and last != last_added: + regex += "-" + re.escape(last) + in_range = False + last = c + else: + if in_range: + regex += "-" + re.escape(c) + return regex + + +valid_description_regex_base = '^[%s]*$' + +valid_description_regex = valid_description_regex_base % ( + _build_regex_range()) + + +name = { + 'type': ['string', 'null'], 'minLength': 0, 'maxLength': 255, + 'format': 'name' +} + + +description = { + 'type': ['string', 'null'], 'minLength': 0, 'maxLength': 255, + 'pattern': valid_description_regex, +} + + +boolean = { + 'type': ['boolean', 'string'], + 'enum': [True, 'True', 'TRUE', 'true', '1', 'ON', 'On', 'on', + 'YES', 'Yes', 'yes', 'y', 't', + False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off', 'off', + 'NO', 'No', 'no', 'n', 'f'], +} + + +uuid = { + 'type': 'string', 'format': 'uuid' +} + + +metadata = { + 'type': ['object', 'null'], + 'patternProperties': { + '^[a-zA-Z0-9-_:.# ]{1,255}$': { + 'type': ['boolean', 'string'] + } + }, + 'additionalProperties': False +} + +parameters = { + 'type': 'object', + 'patternProperties': { + '^[a-zA-Z0-9-_:.# ]{1,255}$': metadata + }, + 'additionalProperties': False +} + +resources = { + 'type': 'array', + 'items': { + 'type': 'object' + } +} diff --git a/karbor/api/validation/validators.py b/karbor/api/validation/validators.py new file mode 100644 index 00000000..18e9e4dc --- /dev/null +++ b/karbor/api/validation/validators.py @@ -0,0 +1,214 @@ +# 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. + +""" +Internal implementation of request Body validating middleware. + +""" + +import re + +import jsonschema +from jsonschema import exceptions as jsonschema_exc +from oslo_utils import timeutils +from oslo_utils import uuidutils +import six + +from karbor import exception +from karbor.i18n import _ + + +def _soft_validate_additional_properties( + validator, additional_properties_value, param_value, schema): + """Validator function. + + If there are not any properties on the param_value that are not specified + in the schema, this will return without any effect. If there are any such + extra properties, they will be handled as follows: + + - if the validator passed to the method is not of type "object", this + method will return without any effect. + - if the 'additional_properties_value' parameter is True, this method will + return without any effect. + - if the schema has an additionalProperties value of True, the extra + properties on the param_value will not be touched. + - if the schema has an additionalProperties value of False and there + aren't patternProperties specified, the extra properties will be stripped + from the param_value. + - if the schema has an additionalProperties value of False and there + are patternProperties specified, the extra properties will not be + touched and raise validation error if pattern doesn't match. + """ + if (not validator.is_type(param_value, "object") or + additional_properties_value): + return + + properties = schema.get("properties", {}) + patterns = "|".join(schema.get("patternProperties", {})) + extra_properties = set() + for prop in param_value: + if prop not in properties: + if patterns: + if not re.search(patterns, prop): + extra_properties.add(prop) + else: + extra_properties.add(prop) + + if not extra_properties: + return + + if patterns: + error = "Additional properties are not allowed (%s %s unexpected)" + if len(extra_properties) == 1: + verb = "was" + else: + verb = "were" + yield jsonschema_exc.ValidationError( + error % (", ".join(repr(extra) for extra in extra_properties), + verb)) + else: + for prop in extra_properties: + del param_value[prop] + + +@jsonschema.FormatChecker.cls_checks('date-time') +def _validate_datetime_format(param_value): + try: + timeutils.parse_isotime(param_value) + except ValueError: + return False + else: + return True + + +@jsonschema.FormatChecker.cls_checks('name', exception.InvalidName) +def _validate_name(param_value): + if not param_value: + msg = "The 'name' can not be None." + raise exception.InvalidName(reason=msg) + elif len(param_value.strip()) == 0: + msg = "The 'name' can not be empty." + raise exception.InvalidName(reason=msg) + return True + + +@jsonschema.FormatChecker.cls_checks('uuid') +def _validate_uuid_format(instance): + return uuidutils.is_uuid_like(instance) + + +class FormatChecker(jsonschema.FormatChecker): + """A FormatChecker can output the message from cause exception + + We need understandable validation errors messages for users. When a + custom checker has an exception, the FormatChecker will output a + readable message provided by the checker. + """ + + def check(self, param_value, format): + """Check whether the param_value conforms to the given format. + + :argument param_value: the param_value to check + :type: any primitive type (str, number, bool) + :argument str format: the format that param_value should conform to + :raises: :exc:`FormatError` if param_value does not conform to format + """ + + if format not in self.checkers: + return + + # For safety reasons custom checkers can be registered with + # allowed exception types. Anything else will fall into the + # default formatter. + func, raises = self.checkers[format] + result, cause = None, None + + try: + result = func(param_value) + except raises as e: + cause = e + if not result: + msg = "%r is not a %r" % (param_value, format) + raise jsonschema_exc.FormatError(msg, cause=cause) + + +class _SchemaValidator(object): + """A validator class + + This class is changed from Draft4Validator to validate minimum/maximum + value of a string number(e.g. '10'). This changes can be removed when + we tighten up the API definition and the XML conversion. + Also FormatCheckers are added for checking data formats which would be + passed through cinder api commonly. + + """ + validator = None + validator_org = jsonschema.Draft4Validator + + def __init__(self, schema, relax_additional_properties=False): + validators = { + 'minimum': self._validate_minimum, + 'maximum': self._validate_maximum, + } + if relax_additional_properties: + validators[ + 'additionalProperties'] = _soft_validate_additional_properties + + validator_cls = jsonschema.validators.extend(self.validator_org, + validators) + format_checker = FormatChecker() + self.validator = validator_cls(schema, format_checker=format_checker) + + def validate(self, *args, **kwargs): + try: + self.validator.validate(*args, **kwargs) + except jsonschema.ValidationError as ex: + if isinstance(ex.cause, exception.InvalidName): + detail = ex.cause.msg + elif len(ex.path) > 0: + detail = _("Invalid input for field/attribute %(path)s." + " Value: %(value)s. %(message)s") % { + 'path': ex.path.pop(), 'value': ex.instance, + 'message': ex.message + } + else: + detail = ex.message + raise exception.ValidationError(detail=detail) + except TypeError as ex: + # NOTE: If passing non string value to patternProperties parameter, + # TypeError happens. Here is for catching the TypeError. + detail = six.text_type(ex) + raise exception.ValidationError(detail=detail) + + def _number_from_str(self, param_value): + try: + value = int(param_value) + except (ValueError, TypeError): + try: + value = float(param_value) + except (ValueError, TypeError): + return None + return value + + def _validate_minimum(self, validator, minimum, param_value, schema): + param_value = self._number_from_str(param_value) + if param_value is None: + return + return self.validator_org.VALIDATORS['minimum'](validator, minimum, + param_value, schema) + + def _validate_maximum(self, validator, maximum, param_value, schema): + param_value = self._number_from_str(param_value) + if param_value is None: + return + return self.validator_org.VALIDATORS['maximum'](validator, maximum, + param_value, schema) diff --git a/karbor/exception.py b/karbor/exception.py index 76de7246..18cff957 100644 --- a/karbor/exception.py +++ b/karbor/exception.py @@ -445,3 +445,11 @@ class PlanLimitExceeded(QuotaError): class UnexpectedOverQuota(QuotaError): message = _("Unexpected over quota on %(name)s.") + + +class InvalidName(Invalid): + message = _("An invalid 'name' value was provided. %(reason)s") + + +class ValidationError(Invalid): + message = "%(detail)s" diff --git a/karbor/tests/unit/api/test_api_validation.py b/karbor/tests/unit/api/test_api_validation.py new file mode 100644 index 00000000..1821441c --- /dev/null +++ b/karbor/tests/unit/api/test_api_validation.py @@ -0,0 +1,500 @@ +# 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 re +import sys + +import fixtures +import six +from six.moves import http_client as http + +from karbor.api import validation +from karbor.api.validation import parameter_types +from karbor import exception +from karbor.tests import base + + +class FakeRequest(object): + environ = {} + + +class ValidationRegex(base.TestCase): + + def test_build_regex_range(self): + + def _get_all_chars(): + for i in range(0x7F): + yield six.unichr(i) + + self.useFixture(fixtures.MonkeyPatch( + 'karbor.api.validation.parameter_types._get_all_chars', + _get_all_chars)) + + r = parameter_types._build_regex_range(ws=False) + self.assertEqual(re.escape('!') + '-' + re.escape('~'), r) + + # if we allow whitespace the range starts earlier + r = parameter_types._build_regex_range(ws=True) + self.assertEqual(re.escape(' ') + '-' + re.escape('~'), r) + + # excluding a character will give us 2 ranges + r = parameter_types._build_regex_range(ws=True, exclude=['A']) + self.assertEqual(re.escape(' ') + '-' + re.escape('@') + + 'B' + '-' + re.escape('~'), r) + + # inverting which gives us all the initial unprintable characters. + r = parameter_types._build_regex_range(ws=False, invert=True) + self.assertEqual(re.escape('\x00') + '-' + re.escape(' '), r) + + # excluding characters that create a singleton. Naively this would be: + # ' -@B-BD-~' which seems to work, but ' -@BD-~' is more natural. + r = parameter_types._build_regex_range(ws=True, exclude=['A', 'C']) + self.assertEqual(re.escape(' ') + '-' + re.escape('@') + + 'B' + 'D' + '-' + re.escape('~'), r) + + # ws=True means the positive regex has printable whitespaces, + # so the inverse will not. The inverse will include things we + # exclude. + r = parameter_types._build_regex_range( + ws=True, exclude=['A', 'B', 'C', 'Z'], invert=True) + self.assertEqual(re.escape('\x00') + '-' + re.escape('\x1f') + + 'A-CZ', r) + + +class APIValidationTestCase(base.TestCase): + + def setUp(self, schema=None): + super(APIValidationTestCase, self).setUp() + self.post = None + + if schema is not None: + @validation.schema(request_body_schema=schema) + def post(req, body): + return 'Validation succeeded.' + + self.post = post + + def check_validation_error(self, method, body, expected_detail, req=None): + if not req: + req = FakeRequest() + try: + method(body=body, req=req,) + except exception.ValidationError as ex: + self.assertEqual(http.BAD_REQUEST, ex.kwargs['code']) + if isinstance(expected_detail, list): + self.assertIn(ex.kwargs['detail'], expected_detail, + 'Exception details did not match expected') + elif not re.match(expected_detail, ex.kwargs['detail']): + self.assertEqual(expected_detail, ex.kwargs['detail'], + 'Exception details did not match expected') + except Exception as ex: + self.fail('An unexpected exception happens: %s' % ex) + else: + self.fail('Any exception did not happen.') + + +class RequiredDisableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + } + super(RequiredDisableTestCase, self).setUp(schema=schema) + + def test_validate_required_disable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'abc': 1}, req=FakeRequest())) + + +class RequiredEnableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'] + } + super(RequiredEnableTestCase, self).setUp(schema=schema) + + def test_validate_required_enable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + + def test_validate_required_enable_fails(self): + detail = "'foo' is a required property" + self.check_validation_error(self.post, body={'abc': 1}, + expected_detail=detail) + + +class AdditionalPropertiesEnableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'], + } + super(AdditionalPropertiesEnableTestCase, self).setUp(schema=schema) + + def test_validate_additionalProperties_enable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1, 'ext': 1}, + req=FakeRequest())) + + +class AdditionalPropertiesDisableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'], + 'additionalProperties': False, + } + super(AdditionalPropertiesDisableTestCase, self).setUp(schema=schema) + + def test_validate_additionalProperties_disable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + + def test_validate_additionalProperties_disable_fails(self): + detail = "Additional properties are not allowed ('ext' was unexpected)" + self.check_validation_error(self.post, body={'foo': 1, 'ext': 1}, + expected_detail=detail) + + +class PatternPropertiesTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'patternProperties': { + '^[a-zA-Z0-9]{1,10}$': { + 'type': 'string' + }, + }, + 'additionalProperties': False, + } + super(PatternPropertiesTestCase, self).setUp(schema=schema) + + def test_validate_patternProperties(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'bar'}, req=FakeRequest())) + + def test_validate_patternProperties_fails(self): + details = [ + "Additional properties are not allowed ('__' was unexpected)", + "'__' does not match any of the regexes: '^[a-zA-Z0-9]{1,10}$'" + ] + self.check_validation_error(self.post, body={'__': 'bar'}, + expected_detail=details) + + details = [ + "'' does not match any of the regexes: '^[a-zA-Z0-9]{1,10}$'", + "Additional properties are not allowed ('' was unexpected)" + ] + self.check_validation_error(self.post, body={'': 'bar'}, + expected_detail=details) + + details = [ + ("'0123456789a' does not match any of the regexes: " + "'^[a-zA-Z0-9]{1,10}$'"), + ("Additional properties are not allowed ('0123456789a' was" + " unexpected)") + ] + self.check_validation_error(self.post, body={'0123456789a': 'bar'}, + expected_detail=details) + + if sys.version[:3] == '3.5': + detail = "expected string or bytes-like object" + else: + detail = "expected string or buffer" + self.check_validation_error(self.post, body={None: 'bar'}, + expected_detail=detail) + + +class StringTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + }, + }, + } + super(StringTestCase, self).setUp(schema=schema) + + def test_validate_string(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'abc'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': ''}, req=FakeRequest())) + + def test_validate_string_fails(self): + detail = ("Invalid input for field/attribute foo. Value: 1." + " 1 is not of type 'string'") + self.check_validation_error(self.post, body={'foo': 1}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.5." + " 1.5 is not of type 'string'") + self.check_validation_error(self.post, body={'foo': 1.5}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: True." + " True is not of type 'string'") + self.check_validation_error(self.post, body={'foo': True}, + expected_detail=detail) + + +class StringLengthTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'minLength': 1, + 'maxLength': 10, + }, + }, + } + super(StringLengthTestCase, self).setUp(schema=schema) + + def test_validate_string_length(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0123456789'}, + req=FakeRequest())) + + def test_validate_string_length_fails(self): + detail = ("Invalid input for field/attribute foo. Value: ." + " '' is too short") + self.check_validation_error(self.post, body={'foo': ''}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0123456789a." + " '0123456789a' is too long") + self.check_validation_error(self.post, body={'foo': '0123456789a'}, + expected_detail=detail) + + +class IntegerTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': ['integer', 'string'], + 'pattern': '^[0-9]+$', + }, + }, + } + super(IntegerTestCase, self).setUp(schema=schema) + + def test_validate_integer(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0123456789'}, + req=FakeRequest())) + + def test_validate_integer_fails(self): + detail = ("Invalid input for field/attribute foo. Value: abc." + " 'abc' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': 'abc'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: True." + " True is not of type 'integer', 'string'") + self.check_validation_error(self.post, body={'foo': True}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0xffff." + " '0xffff' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': '0xffff'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.0." + " 1.0 is not of type 'integer', 'string'") + self.check_validation_error(self.post, body={'foo': 1.0}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.0." + " '1.0' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': '1.0'}, + expected_detail=detail) + + +class IntegerRangeTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': ['integer', 'string'], + 'pattern': '^[0-9]+$', + 'minimum': 1, + 'maximum': 10, + }, + }, + } + super(IntegerRangeTestCase, self).setUp(schema=schema) + + def test_validate_integer_range(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 10}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + + def test_validate_integer_range_fails(self): + detail = ("Invalid input for field/attribute foo. Value: 0." + " 0(.0)? is less than the minimum of 1") + self.check_validation_error(self.post, body={'foo': 0}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 11." + " 11(.0)? is greater than the maximum of 10") + self.check_validation_error(self.post, body={'foo': 11}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0." + " 0(.0)? is less than the minimum of 1") + self.check_validation_error(self.post, body={'foo': '0'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 11." + " 11(.0)? is greater than the maximum of 10") + self.check_validation_error(self.post, body={'foo': '11'}, + expected_detail=detail) + + +class BooleanTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': parameter_types.boolean, + }, + } + super(BooleanTestCase, self).setUp(schema=schema) + + def test_validate_boolean(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': True}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': False}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'True'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'False'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + + def test_validate_boolean_fails(self): + enum_boolean = ("[True, 'True', 'TRUE', 'true', '1', 'ON', 'On'," + " 'on', 'YES', 'Yes', 'yes', 'y', 't'," + " False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off'," + " 'off', 'NO', 'No', 'no', 'n', 'f']") + + detail = ("Invalid input for field/attribute foo. Value: bar." + " 'bar' is not one of %s") % enum_boolean + self.check_validation_error(self.post, body={'foo': 'bar'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 2." + " '2' is not one of %s") % enum_boolean + self.check_validation_error(self.post, body={'foo': '2'}, + expected_detail=detail) + + +class NameTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': parameter_types.name, + }, + } + super(NameTestCase, self).setUp(schema=schema) + + def test_validate_name(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'volume.1'}, + req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'volume 1'}, + req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'a'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': u'\u0434'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': u'\u0434\u2006\ufffd'}, + req=FakeRequest())) + + +class DatetimeTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'format': 'date-time', + }, + }, + } + super(DatetimeTestCase, self).setUp(schema=schema) + + def test_validate_datetime(self): + self.assertEqual('Validation succeeded.', + self.post(body={ + 'foo': '2017-01-14T01:00:00Z'}, req=FakeRequest() + )) diff --git a/karbor/tests/unit/api/v1/test_plans.py b/karbor/tests/unit/api/v1/test_plans.py index 36e57b3d..576fe7cd 100644 --- a/karbor/tests/unit/api/v1/test_plans.py +++ b/karbor/tests/unit/api/v1/test_plans.py @@ -49,41 +49,37 @@ class PlanApiTest(base.TestCase): body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') mock_provider.return_value = fakes.PROVIDER_OS - self.controller.create(req, body) + self.controller.create(req, body=body) self.assertTrue(mock_plan_create.called) def test_plan_create_InvalidBody(self): plan = self._plan_in_request_body() body = {"planxx": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.assertRaises(exc.HTTPUnprocessableEntity, self.controller.create, - req, body) + self.assertRaises(exception.ValidationError, self.controller.create, + req, body=body) def test_plan_create_InvalidProviderId(self): plan = self._plan_in_request_body( name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id="", - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.assertRaises(exception.InvalidInput, self.controller.create, - req, body) + self.assertRaises(exception.ValidationError, self.controller.create, + req, body=body) def test_plan_create_InvalidResources(self): plan = self._plan_in_request_body( name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises(exception.InvalidInput, self.controller.create, - req, body) + req, body=body) @mock.patch( 'karbor.services.protection.rpcapi.ProtectionAPI.show_provider') @@ -93,57 +89,51 @@ class PlanApiTest(base.TestCase): name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, parameters=parameters) body = {"plan": plan} mock_provider.return_value = fakes.PROVIDER_OS req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises(exc.HTTPBadRequest, self.controller.create, - req, body) + req, body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._plan_get') @mock.patch( 'karbor.api.v1.plans.PlansController._plan_update') def test_plan_update(self, mock_plan_update, mock_plan_get): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.controller.update( - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) self.assertTrue(mock_plan_update.called) self.assertTrue(mock_plan_get.called) def test_plan_update_InvalidBody(self): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"planxx": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( - exc.HTTPBadRequest, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + exception.ValidationError, self.controller.update, + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) def test_plan_update_InvalidId(self): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( exc.HTTPNotFound, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) def test_plan_update_InvalidResources(self): - plan = self._plan_in_request_body( + plan = self._plan_update_request_body( name=DEFAULT_NAME, - description=DEFAULT_DESCRIPTION, - provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[{'key1': 'value1'}]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( exception.InvalidInput, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._get_all') @@ -179,7 +169,7 @@ class PlanApiTest(base.TestCase): plan = self._plan_in_request_body(parameters={}) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.controller.create(req, body) + self.controller.create(req, body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._plan_get') @@ -211,12 +201,9 @@ class PlanApiTest(base.TestCase): 'karbor.api.v1.plans.PlansController._plan_get') def test_plan_update_InvalidStatus( self, mock_plan_get): - plan = self._plan_in_request_body( + plan = self._plan_update_request_body( name=DEFAULT_NAME, - description=DEFAULT_DESCRIPTION, - provider_id=DEFAULT_PROVIDER_ID, status=constants.PLAN_STATUS_STARTED, - project_id=DEFAULT_PROJECT_ID, resources=DEFAULT_RESOURCES) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') @@ -224,23 +211,30 @@ class PlanApiTest(base.TestCase): self.assertRaises(exception.InvalidPlan, self.controller.update, req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", - body) + body=body) def _plan_in_request_body(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=DEFAULT_RESOURCES, parameters=DEFAULT_PARAMETERS): plan_req = { 'name': name, 'description': description, 'provider_id': provider_id, - 'status': status, - 'project_id': project_id, 'resources': resources, 'parameters': parameters, } return plan_req + + def _plan_update_request_body(self, name=DEFAULT_NAME, + status=constants.PLAN_STATUS_STARTED, + resources=DEFAULT_RESOURCES): + plan_req = { + 'name': name, + 'resources': resources, + 'status': status, + } + + return plan_req diff --git a/karbor/tests/unit/api/v1/test_scheduled_operation.py b/karbor/tests/unit/api/v1/test_scheduled_operation.py index 78c2eaea..a8d3ce07 100644 --- a/karbor/tests/unit/api/v1/test_scheduled_operation.py +++ b/karbor/tests/unit/api/v1/test_scheduled_operation.py @@ -208,5 +208,5 @@ class ScheduledOperationApiTest(base.TestCase): controller = plan_api.PlansController() mock_provider.return_value = fakes.PROVIDER_OS req = fakes.HTTPRequest.blank('/v1/plans') - plan = controller.create(req, create_plan_param) + plan = controller.create(req, body=create_plan_param) return plan['plan'] diff --git a/requirements.txt b/requirements.txt index 3fd3d419..b73be98c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ croniter>=0.3.4 # MIT License eventlet!=0.18.3,!=0.20.1,<0.21.0,>=0.18.2 # MIT greenlet>=0.4.10 # MIT icalendar>=3.10 # BSD +jsonschema<3.0.0,>=2.6.0 # MIT keystoneauth1>=3.2.0 # Apache-2.0 keystonemiddleware>=4.17.0 # Apache-2.0 kubernetes>=1.0.0 # Apache-2.0