From 5420c0bf5fce14ff1b004841c7b6f152bc2009ea Mon Sep 17 00:00:00 2001 From: Dinesh Bhor Date: Wed, 4 Oct 2017 16:02:34 +0530 Subject: [PATCH] V3 jsonschema validation: base schema framework This patch adds a base jsonschema framework which will be used by other subsequent patches for adding jsonschema validation support for v3 APIs. This patch follows the Nova-Schema-framework: https://github.com/openstack/nova/tree/master/nova/api/validation Depends-On: I4b1140c20c68821eaf0849c9ee551ff9b1c27deb Partial-Implements: bp json-schema-validation Co-author: Pooja Jadhav Change-Id: If40029a6ab9b15c292f9297f5bf56aec9621a7d9 --- cinder/api/schemas/__init__.py | 0 cinder/api/validation/__init__.py | 64 +++ cinder/api/validation/parameter_types.py | 137 +++++ cinder/api/validation/validators.py | 211 ++++++++ cinder/exception.py | 4 + cinder/tests/unit/api/test_api_validation.py | 505 +++++++++++++++++++ requirements.txt | 1 + 7 files changed, 922 insertions(+) create mode 100644 cinder/api/schemas/__init__.py create mode 100644 cinder/api/validation/__init__.py create mode 100644 cinder/api/validation/parameter_types.py create mode 100644 cinder/api/validation/validators.py create mode 100644 cinder/tests/unit/api/test_api_validation.py diff --git a/cinder/api/schemas/__init__.py b/cinder/api/schemas/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/api/validation/__init__.py b/cinder/api/validation/__init__.py new file mode 100644 index 00000000000..5f1e586f898 --- /dev/null +++ b/cinder/api/validation/__init__.py @@ -0,0 +1,64 @@ +# Copyright (C) 2017 NTT DATA +# 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. + +""" +Request Body validating middleware. + +""" + +import functools + +from cinder.api.openstack import api_version_request as api_version +from cinder.api.validation import validators + + +def schema(request_body_schema, min_version=None, max_version=None): + """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 + :param min_version: A string of two numerals. X.Y indicating the minimum + version of the JSON-Schema to validate against. + :param max_version: A string of two numerals. X.Y indicating the maximum + version of the JSON-Schema to validate against. + + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + min_ver = api_version.APIVersionRequest(min_version) + max_ver = api_version.APIVersionRequest(max_version) + + if 'req' in kwargs: + ver = kwargs['req'].api_version_request + else: + ver = args[1].api_version_request + + if ver.matches(min_ver, max_ver): + # Only validate against the schema if it lies within + # the version range specified. Note that if both min + # and max are not specified the validator will always + # be run. + schema_validator = validators._SchemaValidator( + request_body_schema) + schema_validator.validate(kwargs['body']) + + return func(*args, **kwargs) + return wrapper + + return add_validator diff --git a/cinder/api/validation/parameter_types.py b/cinder/api/validation/parameter_types.py new file mode 100644 index 00000000000..9a909796cf4 --- /dev/null +++ b/cinder/api/validation/parameter_types.py @@ -0,0 +1,137 @@ +# Copyright (C) 2017 NTT DATA +# 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. + +""" +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', 'minLength': 1, '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'], +} diff --git a/cinder/api/validation/validators.py b/cinder/api/validation/validators.py new file mode 100644 index 00000000000..d3d4526ba79 --- /dev/null +++ b/cinder/api/validation/validators.py @@ -0,0 +1,211 @@ +# Copyright (C) 2017 NTT DATA +# 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. + +""" +Internal implementation of request Body validating middleware. + +""" + +import re + +import jsonschema +from jsonschema import exceptions as jsonschema_exc +from oslo_utils import timeutils +import six + +from cinder import exception +from cinder.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 + + +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/cinder/exception.py b/cinder/exception.py index 48edd2ffd5c..1426a35d1ae 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -1336,3 +1336,7 @@ class ErrorInParsingArguments(VolumeDriverException): # GPFS driver class GPFSDriverUnsupportedOperation(VolumeBackendAPIException): message = _("GPFS driver unsupported operation: %(msg)s") + + +class InvalidName(Invalid): + message = _("An invalid 'name' value was provided. %(reason)s") diff --git a/cinder/tests/unit/api/test_api_validation.py b/cinder/tests/unit/api/test_api_validation.py new file mode 100644 index 00000000000..a6a140d4c86 --- /dev/null +++ b/cinder/tests/unit/api/test_api_validation.py @@ -0,0 +1,505 @@ +# Copyright (C) 2017 NTT DATA +# 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. + +import re +import sys + +import fixtures +import six +from six.moves import http_client as http + +from cinder.api.openstack import api_version_request as api_version +from cinder.api import validation +from cinder.api.validation import parameter_types +from cinder import exception +from cinder import test + + +class FakeRequest(object): + api_version_request = api_version.APIVersionRequest("3.0") + environ = {} + + +class ValidationRegex(test.TestCase): + + def test_build_regex_range(self): + + def _get_all_chars(): + for i in range(0x7F): + yield six.unichr(i) + + self.useFixture(fixtures.MonkeyPatch( + 'cinder.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(test.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/requirements.txt b/requirements.txt index 5e75cbca360..12f87cc2e98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ eventlet!=0.18.3,!=0.20.1,<0.21.0,>=0.18.2 # MIT greenlet>=0.4.10 # MIT httplib2>=0.9.1 # MIT iso8601>=0.1.11 # MIT +jsonschema<3.0.0,>=2.6.0 # MIT ipaddress>=1.0.16;python_version<'3.3' # PSF keystoneauth1>=3.2.0 # Apache-2.0 keystonemiddleware>=4.17.0 # Apache-2.0