Merge "api: Add validation middleware"
This commit is contained in:
commit
1e0ef4aa00
|
@ -27,15 +27,53 @@ from manila.api.openstack import wsgi
|
|||
from manila.i18n import _
|
||||
|
||||
openstack_api_opts = [
|
||||
cfg.StrOpt('project_id_regex',
|
||||
default=r"[0-9a-f\-]+",
|
||||
help=r'The validation regex for project_ids used in urls. '
|
||||
r'This defaults to [0-9a-f\\-]+ if not set, '
|
||||
r'which matches normal uuids created by keystone.'),
|
||||
cfg.StrOpt(
|
||||
'project_id_regex',
|
||||
default=r'[0-9a-f\-]+',
|
||||
help=(
|
||||
r'The validation regex for project_ids used in URLs. '
|
||||
r'This defaults to [0-9a-f\\-]+ if not set, '
|
||||
r'which matches normal uuids created by keystone.'
|
||||
),
|
||||
),
|
||||
]
|
||||
validation_opts = [
|
||||
cfg.StrOpt(
|
||||
'response_validation',
|
||||
choices=(
|
||||
(
|
||||
'error',
|
||||
'Raise a HTTP 500 (Server Error) for responses that fail '
|
||||
'schema validation',
|
||||
),
|
||||
(
|
||||
'warn',
|
||||
'Log a warning for responses that fail schema validation',
|
||||
),
|
||||
(
|
||||
'ignore',
|
||||
'Ignore schema validation failures',
|
||||
),
|
||||
),
|
||||
default='warn',
|
||||
help="""\
|
||||
Configure validation of API responses.
|
||||
|
||||
``warn`` is the current recommendation for production environments. If you find
|
||||
it necessary to enable the ``ignore`` option, please report the issues you are
|
||||
seeing to the Manila team so we can improve our schemas.
|
||||
|
||||
``error`` should not be used in a production environment. This is because
|
||||
schema validation happens *after* the response body has been generated, meaning
|
||||
any side effects will still happen and the call may be non-idempotent despite
|
||||
the user receiving a HTTP 500 error.
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.register_opts(openstack_api_opts)
|
||||
CONF.register_opts(validation_opts, group='api')
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
# 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.
|
||||
|
||||
"""API request/response validating middleware."""
|
||||
|
||||
import functools
|
||||
import typing as ty
|
||||
|
||||
from oslo_serialization import jsonutils
|
||||
import webob
|
||||
|
||||
from manila.api.openstack import api_version_request as api_version
|
||||
from manila.api.openstack import wsgi
|
||||
from manila.api.validation import validators
|
||||
from manila import exception
|
||||
from manila.i18n import _
|
||||
|
||||
|
||||
def validated(cls):
|
||||
cls._validated = True
|
||||
return cls
|
||||
|
||||
|
||||
def _schema_validator(
|
||||
schema: ty.Dict[str, ty.Any],
|
||||
target: ty.Dict[str, ty.Any],
|
||||
min_version: ty.Optional[str],
|
||||
max_version: ty.Optional[str],
|
||||
args: ty.Any,
|
||||
kwargs: ty.Any,
|
||||
is_body: bool = True,
|
||||
):
|
||||
"""A helper method to execute JSON Schema Validation.
|
||||
|
||||
This method checks the request version whether matches the specified
|
||||
``max_version`` and ``min_version``. If the version range matches the
|
||||
request, we validate ``schema`` against ``target``. A failure will result
|
||||
in ``ValidationError`` being raised.
|
||||
|
||||
:param schema: The JSON Schema schema used to validate the target.
|
||||
:param target: The target to be validated by the schema.
|
||||
:param min_version: A string indicating the minimum API version ``schema``
|
||||
applies against.
|
||||
:param max_version: A string indicating the maximum API version ``schema``
|
||||
applies against.
|
||||
:param args: Positional arguments which passed into original method.
|
||||
:param kwargs: Keyword arguments which passed into original method.
|
||||
:param is_body: Whether ``target`` is a HTTP request body or not.
|
||||
:returns: None.
|
||||
:raises: ``ValidationError`` if validation fails.
|
||||
"""
|
||||
min_ver = api_version.APIVersionRequest(min_version)
|
||||
max_ver = api_version.APIVersionRequest(max_version)
|
||||
|
||||
# NOTE: The request object is always the second argument. However, numerous
|
||||
# unittests pass in the request object via kwargs instead so we handle that
|
||||
# as well.
|
||||
# TODO(stephenfin): Fix unit tests so we don't have to to do this
|
||||
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(schema, is_body=is_body)
|
||||
schema_validator.validate(target)
|
||||
|
||||
|
||||
def request_body_schema(
|
||||
schema: ty.Dict[str, ty.Any],
|
||||
min_version: ty.Optional[str] = None,
|
||||
max_version: ty.Optional[str] = None,
|
||||
):
|
||||
"""Register a schema to validate request body.
|
||||
|
||||
``schema`` will be used for validating the request body just before the API
|
||||
method is executed.
|
||||
|
||||
:param schema: The JSON Schema schema used to validate the target.
|
||||
:param min_version: A string indicating the minimum API version ``schema``
|
||||
applies against.
|
||||
:param max_version: A string indicating the maximum API version ``schema``
|
||||
applies against.
|
||||
"""
|
||||
|
||||
def add_validator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
_schema_validator(
|
||||
schema,
|
||||
kwargs['body'],
|
||||
min_version,
|
||||
max_version,
|
||||
args,
|
||||
kwargs,
|
||||
is_body=True,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._request_body_schema = schema
|
||||
|
||||
return wrapper
|
||||
|
||||
return add_validator
|
||||
|
||||
|
||||
def request_query_schema(
|
||||
schema: ty.Dict[str, ty.Any],
|
||||
min_version: ty.Optional[str] = None,
|
||||
max_version: ty.Optional[str] = None,
|
||||
):
|
||||
"""Register a schema to validate request query string parameters.
|
||||
|
||||
``schema`` will be used for validating request query strings just before
|
||||
the API method is executed.
|
||||
|
||||
:param schema: The JSON Schema schema used to validate the target.
|
||||
:param min_version: A string indicating the minimum API version ``schema``
|
||||
applies against.
|
||||
:param max_version: A string indicating the maximum API version ``schema``
|
||||
applies against.
|
||||
"""
|
||||
|
||||
def add_validator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# NOTE: The request object is always the second argument. However,
|
||||
# numerous unittests pass in the request object via kwargs instead
|
||||
# so we handle that as well.
|
||||
# TODO(stephenfin): Fix unit tests so we don't have to to do this
|
||||
if 'req' in kwargs:
|
||||
req = kwargs['req']
|
||||
else:
|
||||
req = args[1]
|
||||
|
||||
# NOTE: The webob package throws UnicodeError when param cannot be
|
||||
# decoded. Catch this and raise HTTP 400.
|
||||
try:
|
||||
query = req.GET.dict_of_lists()
|
||||
except UnicodeDecodeError:
|
||||
msg = _('Query string is not UTF-8 encoded')
|
||||
raise exception.ValidationError(msg)
|
||||
|
||||
_schema_validator(
|
||||
schema,
|
||||
query,
|
||||
min_version,
|
||||
max_version,
|
||||
args,
|
||||
kwargs,
|
||||
is_body=True,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._request_query_schema = schema
|
||||
|
||||
return wrapper
|
||||
|
||||
return add_validator
|
||||
|
||||
|
||||
def response_body_schema(
|
||||
schema: ty.Dict[str, ty.Any],
|
||||
min_version: ty.Optional[str] = None,
|
||||
max_version: ty.Optional[str] = None,
|
||||
):
|
||||
"""Register a schema to validate response body.
|
||||
|
||||
``schema`` will be used for validating the response body just after the API
|
||||
method is executed.
|
||||
|
||||
:param schema: The JSON Schema schema used to validate the target.
|
||||
:param min_version: A string indicating the minimum API version ``schema``
|
||||
applies against.
|
||||
:param max_version: A string indicating the maximum API version ``schema``
|
||||
applies against.
|
||||
"""
|
||||
|
||||
def add_validator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
response = func(*args, **kwargs)
|
||||
|
||||
# NOTE(stephenfin): If our response is an object, we need to
|
||||
# serializer and deserialize to convert e.g. date-time to strings
|
||||
if isinstance(response, wsgi.ResponseObject):
|
||||
serializer = wsgi.JSONDictSerializer()
|
||||
_body = serializer.serialize(response.obj)
|
||||
# TODO(stephenfin): We should replace all instances of this with
|
||||
# wsgi.ResponseObject
|
||||
elif isinstance(response, webob.Response):
|
||||
_body = response.body
|
||||
else:
|
||||
serializer = wsgi.JSONDictSerializer()
|
||||
_body = serializer.serialize(response)
|
||||
|
||||
if _body == b'':
|
||||
body = None
|
||||
else:
|
||||
body = jsonutils.loads(_body)
|
||||
|
||||
_schema_validator(
|
||||
schema,
|
||||
body,
|
||||
min_version,
|
||||
max_version,
|
||||
args,
|
||||
kwargs,
|
||||
is_body=True,
|
||||
)
|
||||
return response
|
||||
|
||||
wrapper._response_body_schema = schema
|
||||
|
||||
return wrapper
|
||||
|
||||
return add_validator
|
|
@ -0,0 +1,45 @@
|
|||
# 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 API requests/responses."""
|
||||
|
||||
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',
|
||||
],
|
||||
}
|
|
@ -0,0 +1,277 @@
|
|||
# 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/response validating middleware."""
|
||||
|
||||
import re
|
||||
|
||||
import jsonschema
|
||||
from jsonschema import exceptions as jsonschema_exc
|
||||
from oslo_utils import timeutils
|
||||
from oslo_utils import uuidutils
|
||||
import webob.exc
|
||||
|
||||
from manila import exception
|
||||
from manila.i18n import _
|
||||
from manila import utils
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def _validate_string_length(
|
||||
value,
|
||||
entity_name,
|
||||
mandatory=False,
|
||||
min_length=0,
|
||||
max_length=None,
|
||||
remove_whitespaces=False,
|
||||
):
|
||||
"""Check the length of specified string.
|
||||
|
||||
:param value: the value of the string
|
||||
:param entity_name: the name of the string
|
||||
:mandatory: string is mandatory or not
|
||||
:param min_length: the min_length of the string
|
||||
:param max_length: the max_length of the string
|
||||
:param remove_whitespaces: True if trimming whitespaces is needed else
|
||||
False
|
||||
"""
|
||||
if not mandatory and not value:
|
||||
return True
|
||||
|
||||
if mandatory and not value:
|
||||
msg = _("The '%s' can not be None.") % entity_name
|
||||
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||
|
||||
if remove_whitespaces:
|
||||
value = value.strip()
|
||||
|
||||
utils.check_string_length(
|
||||
value, entity_name, min_length=min_length, max_length=max_length
|
||||
)
|
||||
|
||||
|
||||
@jsonschema.FormatChecker.cls_checks('date-time')
|
||||
def _validate_datetime_format(instance: object) -> bool:
|
||||
# format checks constrain to the relevant primitive type
|
||||
# https://github.com/OAI/OpenAPI-Specification/issues/3148
|
||||
if not isinstance(instance, str):
|
||||
return True
|
||||
try:
|
||||
timeutils.parse_isotime(instance)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
@jsonschema.FormatChecker.cls_checks('uuid')
|
||||
def _validate_uuid_format(instance: object) -> bool:
|
||||
# format checks constrain to the relevant primitive type
|
||||
# https://github.com/OAI/OpenAPI-Specification/issues/3148
|
||||
if not isinstance(instance, str):
|
||||
return True
|
||||
|
||||
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.
|
||||
|
||||
:param param_value: the param_value to check
|
||||
:type: any primitive type (str, number, bool)
|
||||
:param 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 Draft202012Validator to validate minimum/maximum
|
||||
value of a string number(e.g. '10').
|
||||
|
||||
In addition, FormatCheckers are added for checking data formats which are
|
||||
common in the Manila API.
|
||||
"""
|
||||
|
||||
validator = None
|
||||
validator_org = jsonschema.Draft202012Validator
|
||||
|
||||
def __init__(
|
||||
self, schema, relax_additional_properties=False, is_body=True
|
||||
):
|
||||
self.is_body = is_body
|
||||
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 len(ex.path) > 0:
|
||||
if self.is_body:
|
||||
# NOTE: For consistency across OpenStack services, this
|
||||
# error message has been written in a similar format as
|
||||
# WSME errors.
|
||||
detail = _(
|
||||
'Invalid input for field/attribute %(path)s. '
|
||||
'Value: %(value)s. %(message)s'
|
||||
) % {
|
||||
'path': ex.path.pop(),
|
||||
'value': ex.instance,
|
||||
'message': ex.message,
|
||||
}
|
||||
else:
|
||||
# NOTE: We use 'ex.path.popleft()' instead of
|
||||
# 'ex.path.pop()'. This is due to the structure of query
|
||||
# parameters which is a dict with key as name and value is
|
||||
# list. As such, the first item in the 'ex.path' is the key
|
||||
# and second item is the index of list in the value. We
|
||||
# need the key as the parameter name in the error message
|
||||
# so we pop the first value out of 'ex.path'.
|
||||
detail = _(
|
||||
'Invalid input for query parameters %(path)s. '
|
||||
'Value: %(value)s. %(message)s'
|
||||
) % {
|
||||
'path': ex.path.popleft(),
|
||||
'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 = str(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
|
||||
)
|
|
@ -199,6 +199,10 @@ class InvalidCapacity(Invalid):
|
|||
message = _("Invalid capacity: %(name)s = %(value)s.")
|
||||
|
||||
|
||||
class ValidationError(Invalid):
|
||||
message = "%(detail)s"
|
||||
|
||||
|
||||
class NotFound(ManilaException):
|
||||
message = _("Resource could not be found.")
|
||||
code = 404
|
||||
|
|
|
@ -0,0 +1,547 @@
|
|||
# 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.
|
||||
|
||||
from http import client as http
|
||||
import re
|
||||
|
||||
from manila.api.openstack import api_version_request as api_version
|
||||
from manila.api import validation
|
||||
from manila.api.validation import parameter_types
|
||||
from manila import exception
|
||||
from manila import test
|
||||
|
||||
|
||||
class FakeRequest(object):
|
||||
api_version_request = api_version.APIVersionRequest("3.0")
|
||||
environ = {}
|
||||
|
||||
|
||||
class APIValidationTestCase(test.TestCase):
|
||||
def setUp(self, schema=None):
|
||||
super().setUp()
|
||||
self.post = None
|
||||
|
||||
if schema is not None:
|
||||
|
||||
@validation.request_body_schema(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().setUp(schema=schema)
|
||||
|
||||
def test_validate_required_disable(self):
|
||||
self.assertEqual(
|
||||
'Validation succeeded.',
|
||||
self.post(body={'foo': 1}, req=FakeRequest()),
|
||||
)
|
||||
|
||||
|
||||
class RequiredEnableTestCase(APIValidationTestCase):
|
||||
def setUp(self):
|
||||
schema = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'foo': {
|
||||
'type': 'integer',
|
||||
},
|
||||
},
|
||||
'required': ['foo'],
|
||||
}
|
||||
super().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().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().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().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
|
||||
)
|
||||
|
||||
detail = "expected string or bytes-like object"
|
||||
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().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().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):
|
||||
# checks for jsonschema output from 3.2.x and 4.21.x
|
||||
detail = (
|
||||
"Invalid input for field/attribute foo. Value: . "
|
||||
"'' (is too short|should be non-empty)"
|
||||
)
|
||||
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().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.01. "
|
||||
"1.01 is not of type 'integer', 'string'"
|
||||
)
|
||||
self.check_validation_error(
|
||||
self.post, body={'foo': 1.01}, 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().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().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 DatetimeTestCase(APIValidationTestCase):
|
||||
def setUp(self):
|
||||
schema = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'foo': {
|
||||
'type': ['string', 'null'],
|
||||
'format': 'date-time',
|
||||
},
|
||||
},
|
||||
}
|
||||
super().setUp(schema=schema)
|
||||
|
||||
def test_validate_datetime(self):
|
||||
self.assertEqual(
|
||||
'Validation succeeded.',
|
||||
self.post(body={'foo': '2017-01-14T01:00:00Z'}, req=FakeRequest()),
|
||||
)
|
||||
self.assertEqual(
|
||||
'Validation succeeded.',
|
||||
self.post(body={'foo': None}, req=FakeRequest()),
|
||||
)
|
||||
|
||||
def test_validate_datetime_fails(self):
|
||||
detail = (
|
||||
"Invalid input for field/attribute foo. Value: True. "
|
||||
"True is not of type 'string', 'null'"
|
||||
)
|
||||
self.check_validation_error(
|
||||
self.post, body={'foo': True}, expected_detail=detail
|
||||
)
|
||||
|
||||
detail = (
|
||||
"Invalid input for field/attribute foo. Value: 123. "
|
||||
"'123' is not a 'date-time'"
|
||||
)
|
||||
self.check_validation_error(
|
||||
self.post, body={'foo': '123'}, expected_detail=detail
|
||||
)
|
|
@ -19,6 +19,8 @@ import os
|
|||
from oslo_policy import opts
|
||||
from oslo_service import wsgi
|
||||
|
||||
# some of these are imported for their side-effects
|
||||
from manila.api import openstack # noqa
|
||||
from manila.common import config
|
||||
|
||||
CONF = config.CONF
|
||||
|
@ -82,6 +84,8 @@ def set_defaults(conf):
|
|||
|
||||
_safe_set_of_opts(conf, 'unity_server_meta_pool', 'nas_server_pool')
|
||||
|
||||
conf.set_default('response_validation', 'error', group='api')
|
||||
|
||||
|
||||
def _safe_set_of_opts(conf, *args, **kwargs):
|
||||
try:
|
||||
|
|
Loading…
Reference in New Issue