diff --git a/flask_request_validator/__init__.py b/flask_request_validator/__init__.py index c27aab5..7cc021c 100755 --- a/flask_request_validator/__init__.py +++ b/flask_request_validator/__init__.py @@ -10,19 +10,5 @@ from .nested_json import JsonParam from .valid_request import ValidRequest from .after_param import AbstractAfterParam -from .rules import ( - AbstractRule, - CompositeRule, - Enum, - IsDatetimeIsoFormat, - IsEmail, - MaxLength, - MinLength, - Max, - Min, - NotEmpty, - Pattern, - Datetime, - Number, - REGEX_EMAIL, -) +from .rules import * +from .files import File, FileChain diff --git a/flask_request_validator/exceptions.py b/flask_request_validator/exceptions.py index c4e658b..a93edd7 100755 --- a/flask_request_validator/exceptions.py +++ b/flask_request_validator/exceptions.py @@ -1,4 +1,4 @@ -from typing import List, Union, Dict, Any +from typing import List, Union, Dict, Any, Iterable class RequestError(Exception): @@ -136,7 +136,10 @@ def __str__(self) -> str: return 'invalid email address' -class NumberError(RuleError): +class NumberError(TypeConversionError, RuleError): + """ + @deprecated v5.0. Number rules should raise TypeConversionError + """ def __str__(self) -> str: return 'expected number' @@ -181,6 +184,40 @@ def __str__(self) -> str: return '. '.join(formatted) +class FileError(RequestError): + def __init__(self, file_name: str) -> None: + self.file_name = file_name + + +class FilesLimitError(FileError): + def __init__(self, files_limit: int) -> None: + self.files_limit = files_limit + + +class FileSizeError(FileError): + def __init__(self, file_name: str, file_size: int, size_limit: int) -> None: + self.file_size = file_size + self.size_limit = size_limit + super().__init__(file_name) + + +class FileNameError(FileError): + def __init__(self, file_names: list, names_pattern: str) -> None: + self.names_pattern = names_pattern + self.file_names = file_names + + +class FileMimeTypeError(FileError): + def __init__(self, file_name: str, mime_type: str, available_mime_types: Iterable) -> None: + self.mime_type = mime_type + self.available_mime_types = available_mime_types + super().__init__(file_name) + + +class FileMissingError(FileError): + pass + + class InvalidRequestError(RequestError): def __init__( self, @@ -188,8 +225,10 @@ def __init__( form: Dict[str, RulesError], path: Dict[str, RulesError], json: Union[List[JsonError], Dict[str, RulesError]], + files: List[FileError], ): self.json = json # list when nested json validation self.path = path self.get = get self.form = form + self.files = files diff --git a/flask_request_validator/files.py b/flask_request_validator/files.py new file mode 100644 index 0000000..f0ac490 --- /dev/null +++ b/flask_request_validator/files.py @@ -0,0 +1,58 @@ +import mimetypes +import re +from typing import Iterable, Dict + +from werkzeug.datastructures import FileStorage + +from .exceptions import FilesLimitError, FileMimeTypeError, FileSizeError, FileNameError, FileMissingError + + +class File: + def __init__(self, name: str, mime_types: Iterable, max_size: int) -> None: + self._mime_types = mime_types + self._max_size = max_size + self._name = name + + def validate(self, files: Dict[str, FileStorage]): + file = files.get(self._name) + + if not file: + raise FileMissingError(self._name) + if file.mimetype not in self._mime_types: + raise FileMimeTypeError(file.name, file.mimetype, self._mime_types) + + file_length = len(file.read()) + if file_length > self._max_size: + raise FileSizeError(file.name, file_length, self._max_size) + + +class FileChain: + def __init__(self, mime_types: Iterable, max_size: int, max_files: int, name_pattern: str = '') -> None: + self._name_pattern = name_pattern + self._max_files = max_files + self._mime_types = mime_types + self._max_size = max_size + + def validate(self, files: Dict[str, FileStorage]) -> None: + if len(files) > self._max_files: + raise FilesLimitError(self._max_files) + + bad_names = [] + mime_types = mimetypes.types_map + for name, file in files.items(): + if self._name_pattern: + pattern = re.compile(self._name_pattern) + file_name = file.filename + for ext in mime_types.keys(): + if file_name.endswith(ext): + file_name = file_name[0:file_name.rfind(ext)] + break + + if not pattern.match(file_name): + bad_names.append(file.filename) + continue + + File(name, self._mime_types, self._max_size).validate(files) + + if bad_names: + raise FileNameError(bad_names, self._name_pattern) diff --git a/flask_request_validator/rules.py b/flask_request_validator/rules.py index f6c335d..289a63c 100755 --- a/flask_request_validator/rules.py +++ b/flask_request_validator/rules.py @@ -1,9 +1,9 @@ +import numbers import re import sys -import numbers from abc import ABC, abstractmethod +from copy import deepcopy from datetime import datetime -from typing import Iterable from .dt_utils import dt_from_iso from .exceptions import * @@ -15,18 +15,26 @@ class AbstractRule(ABC): @abstractmethod def validate(self, value: Any) -> Any: """ - The returned value does not have to match the input value. - Feel free to implement conversion logic. + The returned value does not have to match the input value. + Feel free to implement conversion logic. - :param Any value: - :raises RuleError: + :param Any value: + :raises: + TypeConversionError: when a value type is incorrect. skips logical checks + RuleError: if TypeConversionError was not raised but logic restrictions """ pass class CompositeRule(AbstractRule): def __init__(self, *rules: AbstractRule) -> None: - self._rules = rules + type_checkers = (Number, BoolRule, IntRule, FloatRule) + rules_by_priority = sorted(rules, key=lambda x: 0 if isinstance(x, type_checkers) else 1) + if len(rules_by_priority) > 1 and isinstance(rules_by_priority[1], type_checkers): + raise WrongUsageError(f'You can use only 1 type. ' + f'Choose one of: {", ".join([t.__name__ for t in type_checkers])}') + + self._rules = rules_by_priority def __iter__(self): for rule in self._rules: @@ -37,10 +45,13 @@ def validate(self, value: Any) -> Any: :raises RulesError: """ errors = [] - new_value = value + new_value = deepcopy(value) for rule in self._rules: try: - new_value = rule.validate(value=value) + new_value = rule.validate(value=new_value) + except TypeConversionError as e: + errors.append(e) + break except RuleError as e: errors.append(e) @@ -173,5 +184,97 @@ def validate(self, value: str) -> datetime: class Number(AbstractRule): def validate(self, value: Any) -> Any: if not isinstance(value, numbers.Number): - raise NumberError + raise NumberError() return value + + +class IntRule(AbstractRule): + """ + >>> IntRule().validate(7) + 7 + >>> IntRule().validate('7') + 7 # int + """ + def __init__(self, str_to_int: bool = True) -> None: + self._str_to_int = str_to_int + + def validate(self, value: Any) -> Any: + if isinstance(value, int): + return value + + if isinstance(value, str) and self._str_to_int: + try: + return int(value) + except ValueError: + pass + + raise TypeConversionError() + + +class FloatRule(AbstractRule): + """ + >>> FloatRule().validate(9.99) + 9.99 + >>> FloatRule({','}).validate('9.99') + 9.99 # float + """ + def __init__(self, delimiters: set = None) -> None: + self._delimiters = delimiters or {} + + def validate(self, value: Any) -> Any: + if isinstance(value, float): + return value + + if isinstance(value, str): + for char in self._delimiters: + try: + return float(value.replace(char, '.', 1)) + except ValueError: + pass + + raise TypeConversionError() + + +class BoolRule(AbstractRule): + """ + >>> BoolRule().validate(True) + True + >>> BoolRule().validate(False) + False + >>> BoolRule(yes={'plus'}).validate('PluS') + True # bool + >>> BoolRule(yes={1}).validate(1) + True # bool + >>> BoolRule(no={'no'}).validate('No') + False # bool + >>> BoolRule(no={0}).validate(0) + False # bool + """ + def __init__(self, yes: set = None, no: set = None) -> None: + self._yes = yes or set() + self._no = no or set() + + def validate(self, value: Any) -> Any: + if isinstance(value, bool): + return value + + if isinstance(value, int): + for yes in self._yes: + if yes == value: + return True + + for no in self._no: + if no == value: + return False + + if isinstance(value, str): + low_val = value.lower() + for yes in self._yes: + if yes == low_val: + return True + + for no in self._no: + if no == low_val: + return False + + raise TypeConversionError() diff --git a/flask_request_validator/valid_request.py b/flask_request_validator/valid_request.py index 3cb185c..fd6da8b 100644 --- a/flask_request_validator/valid_request.py +++ b/flask_request_validator/valid_request.py @@ -1,6 +1,8 @@ from abc import abstractmethod, ABC from typing import Dict, Any +from flask import Request + class ValidRequest(ABC): @abstractmethod @@ -22,3 +24,7 @@ def get_params(self) -> Dict[str, Any]: @abstractmethod def get_path_params(self) -> Dict[str, Any]: pass + + @abstractmethod + def get_flask_request(self) -> Request: + pass diff --git a/flask_request_validator/validator.py b/flask_request_validator/validator.py index a5a1bb9..d316b1f 100755 --- a/flask_request_validator/validator.py +++ b/flask_request_validator/validator.py @@ -2,13 +2,14 @@ from functools import wraps from typing import Tuple -from flask import request +from flask import request, Request from .after_param import AbstractAfterParam from .exceptions import * from .rules import CompositeRule from .valid_request import ValidRequest from .nested_json import JsonParam +from .files import File, FileChain GET = 'GET' @@ -16,6 +17,7 @@ FORM = 'FORM' HEADER = 'HEADER' JSON = 'JSON' +FILES = ' FILES' PARAM_TYPES = (GET, PATH, FORM, JSON, HEADER) _ALLOWED_TYPES = (str, bool, int, float, dict, list) @@ -46,6 +48,9 @@ def get_params(self) -> Dict[str, Any]: def get_path_params(self) -> Dict[str, Any]: return self._valid_data.get(PATH, dict()) + def get_flask_request(self) -> Request: + return request + class Param: def __init__(self, name, param_type, value_type=None, @@ -57,7 +62,8 @@ def __init__(self, name, param_type, value_type=None, :param list|CompositeRule rules: :param str name: name of param :param str param_type: type of request param (see: PARAM_TYPES) - :raises: UndefinedParamType, NotAllowedType, WrongUsageError + :raises: + WrongUsageError """ if param_type not in PARAM_TYPES: raise WrongUsageError( @@ -84,7 +90,8 @@ def __init__(self, name, param_type, value_type=None, def value_to_type(self, value: Any) -> Any: """ - :raises TypeConversionError: + :raises: + TypeConversionError: """ if self.value_type == bool: if isinstance(value, str): @@ -114,7 +121,8 @@ def value_to_type(self, value: Any) -> Any: def get_value_from_request(self) -> Any: """ - :raises RequiredValueError: + :raises: + RequiredValueError: """ value = None if self.param_type == FORM: @@ -135,13 +143,18 @@ def get_value_from_request(self) -> Any: return value -def validate_params(*params: Union[JsonParam, Param, AbstractAfterParam]): +def validate_params(*params: Union[JsonParam, Param, AbstractAfterParam, File, FileChain]): """ - :raises InvalidHeadersError: - When found invalid headers. Raises before other params validation - :raises InvalidRequestError: - Raises after headers validation if errors found + :raises: + InvalidHeadersError: When found invalid headers. Raises before other params validation + InvalidRequestError: Raises after headers validation if errors found + WrongUsageError: """ + files = [isinstance(f, File) for f in params] + chains = [isinstance(f, FileChain) for f in params] + if any(files) and any(chains): + raise WrongUsageError('it is impossible to use File and FileChain. You should use FileChain or multiple File') + def validate_request(func): @wraps(func) def wrapper(*args, **kwargs): @@ -163,7 +176,7 @@ def wrapper(*args, **kwargs): for type_errors in errors.values(): if type_errors: raise InvalidRequestError(errors[GET], errors[FORM], - errors[PATH], errors[JSON]) + errors[PATH], errors[JSON], errors[FILES]) for param in after_params: param.validate(valid) @@ -176,8 +189,8 @@ def wrapper(*args, **kwargs): def __get_request_errors( params: Tuple[Union[Param, JsonParam], ...], valid: _ValidRequest -) -> Tuple[_ValidRequest, Dict[str, Union[Dict[str, RulesError], List[JsonError]]]]: - errors = {GET: dict(), FORM: dict(), JSON: dict(), HEADER: dict(), PATH: dict()} +) -> Tuple[_ValidRequest, Dict[str, Union[Dict[str, RulesError], List[JsonError], List[FileError]]]]: + errors = {GET: dict(), FORM: dict(), JSON: dict(), HEADER: dict(), PATH: dict(), FILES: []} for param in params: if isinstance(param, JsonParam): value, json_errors = param.validate(request.get_json()) @@ -187,6 +200,13 @@ def __get_request_errors( valid.set_json(value) continue + if isinstance(param, (File, FileChain)): + try: + param.validate(request.files) + except FileError as error: + errors[FILES].append(error) + continue + try: value = param.get_value_from_request() if value is not None: diff --git a/setup.py b/setup.py index 08fe544..85f4cf7 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='flask_request_validator', - version='4.2.2', + version='4.3.0', description='Flask request data validation', long_description=long_description, url='https://github.com/d-ganchar/flask_request_validator', diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..390bd8d --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,147 @@ +import io +from unittest import TestCase + +import flask +from flask_restful import Api +from parameterized import parameterized + +from flask_request_validator import * + + +_app = flask.Flask(__name__) +_test_api = Api(_app, '/v1') + +_app.testing = True + +_app2 = flask.Flask(__name__) + + +@_app2.errorhandler(RequestError) +def handler(e): + return str(e.files), 400 + + +@_app2.route('/issue/85', methods=['POST']) +@validate_params( + File( + mime_types=['application/pdf'], + max_size=22, + name='document', + ), + File( + mime_types=['image/jpeg'], + max_size=22, + name='photo', + ) +) +def issue_85(valid: ValidRequest): + return str(valid.get_flask_request().files.keys()) + + +@_app2.route('/issue/85-chain', methods=['POST']) +@validate_params( + FileChain( + mime_types=['application/pdf', 'image/jpeg'], + max_size=22, + max_files=2, + name_pattern='^[a-z]+$', + ) +) +def issue_85_chain(valid: ValidRequest): + return str(valid.get_flask_request().files.keys()) + + +class TestFiles(TestCase): + def test_wrong_usage(self): + with self.assertRaises(WrongUsageError): + @validate_params( + FileChain( + mime_types=['application/pdf', 'image/jpeg'], + max_size=1024, + max_files=3, + ), + File('test', ['test'], 27), + ) + def route(request: ValidRequest): + pass + + @parameterized.expand([ + ( + dict(), + b"[FileMissingError('document'), FileMissingError('photo')]", + ), + ( + dict(photo=(io.BytesIO(b'very long photo content'), 'photo.jpg')), + b"[FileMissingError('document'), FileSizeError('photo', 23, 22)]", + ), + ( + dict(document=(io.BytesIO(b'very long document content'), 'document.pdf')), + b"[FileSizeError('document', 26, 22), FileMissingError('photo')]", + ), + ( + dict( + document=(io.BytesIO(b'very long document content'), 'document.pdf'), + photo=(io.BytesIO(b'very long photo content'), 'photo.jpg'), + ), + b"[FileSizeError('document', 26, 22), FileSizeError('photo', 23, 22)]", + ), + ( + dict( + document=(io.BytesIO(b'good pdf'), 'document.pdf'), + photo=(io.BytesIO(b'good jpg'), 'photo.jpg'), + ), + b"dict_keys(['document', 'photo'])", + ), + ]) + def test_issue_85(self, data: dict, expected: bytes): + with _app2.test_client() as client: + response = client.post( + '/issue/85', + data=data, + follow_redirects=True, + content_type='multipart/form-data', + ) + + self.assertEqual(response.data, expected) + + @parameterized.expand([ + ( + dict( + document=(io.BytesIO(b'very long document content'), 'document.pdf'), + photo=(io.BytesIO(b'very long photo content'), 'photo.jpg'), + ), + b"[FileSizeError('document', 26, 22)]", + ), + ( + dict( + document=(io.BytesIO(b'bad pdf'), 'd0cumeNT.pdf'), + photo=(io.BytesIO(b'bad jpg'), 'ph0T0.jpg'), + ), + b"[FileNameError(['d0cumeNT.pdf', 'ph0T0.jpg'], '^[a-z]+$')]", + ), + ( + dict( + document=(io.BytesIO(b'good pdf'), 'document.pdf'), + photo=(io.BytesIO(b'good jpg'), 'photo.jpg'), + pic=(io.BytesIO(b'files limit'), 'pic.jpg'), + ), + b"[FilesLimitError(2)]", + ), + ( + dict( + document=(io.BytesIO(b'good pdf'), 'document.pdf'), + photo=(io.BytesIO(b'good jpg'), 'photo.jpg'), + ), + b"dict_keys(['document', 'photo'])", + ), + ]) + def test_issue_85_chain(self, data: dict, expected: bytes): + with _app2.test_client() as client: + response = client.post( + '/issue/85-chain', + data=data, + follow_redirects=True, + content_type='multipart/form-data', + ) + + self.assertEqual(response.data, expected) diff --git a/tests/test_nested_json.py b/tests/test_nested_json.py index fe100b8..21eea98 100644 --- a/tests/test_nested_json.py +++ b/tests/test_nested_json.py @@ -3,24 +3,36 @@ from parameterized import parameterized -from flask_request_validator import JsonParam, Enum, CompositeRule, Min, Max, IsEmail, Number, MinLength +from flask_request_validator import ( + JsonParam as P, + Enum, + CompositeRule, + Min, + Max, + IsEmail, + Number, + MinLength, + IntRule, + FloatRule, + BoolRule, +) from flask_request_validator.exceptions import * class TestJsonParam(unittest.TestCase): - LIST_SCHEMA = JsonParam( + LIST_SCHEMA = P( { - 'person': JsonParam( + 'person': P( { - 'info': JsonParam({ - 'contacts': JsonParam({ - 'phones': JsonParam([Enum('+375', '+49')], as_list=True), - 'networks': JsonParam( + 'info': P({ + 'contacts': P({ + 'phones': P([Enum('+375', '+49')], as_list=True), + 'networks': P( {'name': [Enum('facebook', 'telegram')]}, as_list=True, ), - 'emails': JsonParam([IsEmail()], as_list=True), - 'addresses': JsonParam({'street': []}, required=False), + 'emails': P([IsEmail()], as_list=True), + 'addresses': P({'street': []}, required=False), }), }), }, @@ -28,23 +40,23 @@ class TestJsonParam(unittest.TestCase): }, ) - DICT_SCHEMA = JsonParam( + DICT_SCHEMA = P( { 'street': CompositeRule(Enum('Jakuba Kolasa')), - 'meta': JsonParam( + 'meta': P( { - 'description': JsonParam({ + 'description': P({ 'color': [Enum('green', 'yellow', 'blue')], }, ), - 'buildings': JsonParam({ - 'warehouses': JsonParam({ - 'small': JsonParam({ + 'buildings': P({ + 'warehouses': P({ + 'small': P({ 'count': CompositeRule(Min(0), Max(99)), }), 'large': [Min(1), Max(10)] }), }), - 'not_required': JsonParam({'text': []}, required=False), + 'not_required': P({'text': []}, required=False), }, ), }, @@ -144,7 +156,7 @@ class TestJsonParam(unittest.TestCase): [], ), ]) - def test_validate(self, param: JsonParam, data, exp): + def test_validate(self, param: P, data, exp): value, errors = param.validate(deepcopy(data)) self.assertEqual(len(errors), len(exp)) @@ -169,10 +181,10 @@ def test_validate(self, param: JsonParam, data, exp): ), ]) def test_root_list_valid(self, value): - param = JsonParam({ + param = P({ 'age': [Number()], 'name': [MinLength(1), ], - 'tags': JsonParam({'name': [MinLength(1)]}, required=False, as_list=True) + 'tags': P({'name': [MinLength(1)]}, required=False, as_list=True) }, as_list=True) valid_value, errors = param.validate(deepcopy(value)) @@ -180,10 +192,10 @@ def test_root_list_valid(self, value): self.assertEqual(0, len(errors)) def test_root_list_invalid(self): - param = JsonParam({ + param = P({ 'age': [Number()], 'name': [MinLength(1), ], - 'tags': JsonParam({'name': [MinLength(1)]}, required=False, as_list=True) + 'tags': P({'name': [MinLength(1)]}, required=False, as_list=True) }, as_list=True) # invalid values _, errors = param.validate([ @@ -202,3 +214,36 @@ def test_root_list_invalid(self): # invalid type - dict instead list _, errors = param.validate({'age': 18, 'name': 'test'}) self.assertEqual("[JsonListExpectedError(['root'])]", str(errors)) + + @parameterized.expand([ + # IntRule + ( + P(dict(age=[Min(27), IntRule()], day=[Min(1), IntRule()])), + dict(age=27, day='1'), + dict(age=27, day=1), + ), + ( + P(dict(age=[Min(27), IntRule(False)])), + dict(age='27'), + "[JsonError(['root'], {'age': RulesError(TypeConversionError())}, False)]", + ), + # FloatRule + ( + P(dict(price=[Min(0.69), FloatRule()], size=[Min(0.25), FloatRule({','})])), + dict(price=0.69, size='0,25'), + dict(price=0.69, size=0.25), + ), + # BoolRule + ( + P(dict(yes=[BoolRule()], no=[BoolRule()])), + dict(yes=True, no=False), + dict(yes=True, no=False), + ), + ]) + def test_type_checkers(self, param: P, value: dict, expected: dict or str): + new_val, errors = param.validate(value) + if isinstance(expected, str): + self.assertEqual(expected, str(errors)) + return + + self.assertEqual(new_val, expected) diff --git a/tests/test_rules.py b/tests/test_rules.py index 3483a6b..737b5cc 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -13,136 +13,193 @@ def test_abstract_rule(self) -> None: with self.assertRaises(expected_exception=TypeError): AbstractRule() - def _assert_single_rules_error(self, rules, value, exp_type): - if exp_type: - try: - rules.validate(value) - except RulesError as e: - self.assertTrue(1, len(e.errors)) - self.assertTrue(isinstance(e.errors[0], exp_type)) - - def _assert_rule_error(self, rule, value, exp_type): - if exp_type: - self.assertRaises(exp_type, rule.validate, value) - @parameterized.expand([ - (1.00001, Min(1), Max(2), None), - (1.00000, Min(1), Max(2), None), - (1.99999, Min(1), Max(2), None), - (2.00000, Min(1), Max(2), None), + (1.00001, Min(1), Max(2), 1.00001), + (1.00000, Min(1), Max(2), 1.00000), + (1.99999, Min(1), Max(2), 1.99999), + (2.00000, Min(1), Max(2), 2.00000), (0.000009, Min(1), Max(2), ValueMinError), (1.00000, Min(1, False), Max(2), ValueMinError), (2.0000001, Min(1), Max(2), ValueMaxError), (2.00000, Min(1), Max(2, False), ValueMaxError), - ]) - def test_min_max(self, value, min_rule, max_rule, exp) -> None: - rules = CompositeRule(*[min_rule, max_rule]) - self._assert_single_rules_error(rules, value, exp) - - @parameterized.expand([ - (Pattern(r'^[0-9]*$'), '0', None, ), - (Pattern(r'^[0-9]*$'), '23456', None, ), - (Pattern(r'^[0-9]*$'), 'god is an astronaut', ValuePatternError,), - (Pattern(r'^[0-9]*$'), ' ', ValuePatternError,), - ]) - def test_pattern_rule(self, rule, value, exp) -> None: - self._assert_rule_error(rule, value, exp) - - @parameterized.expand([ - (Enum('thievery corporation', 'bonobo'), 'jimi hendrix', ValueEnumError), - (Enum('thievery corporation', 'bonobo'), 'thievery corporation', None), - (Enum('thievery corporation', 'bonobo'), 'bonobo', None), - ]) - def test_enum_rule(self, rule, value, exp) -> None: - self._assert_rule_error(rule, value, exp) - - @parameterized.expand([ - ('mammal hands', MinLength(11), MaxLength(13), None), + ('mammal hands', MinLength(11), MaxLength(13), 'mammal hands'), ('', MinLength(3), MaxLength(5), ValueMinLengthError), ('mammal hands', MinLength(11), MaxLength(13), ValueMinLengthError), ('#mammal hands#', MinLength(11), MaxLength(13), ValueMaxLengthError), ]) - def test_min_max_length_rule(self, value, min_l, max_l, exp): + def test_composite_min_max_rule(self, value, min_l, max_l, expected): rules = CompositeRule(*[min_l, max_l]) - self._assert_single_rules_error(rules, value, exp) + if not type(expected) is type: + self.assertEqual(rules.validate(value), expected) + return - @parameterized.expand([ - ('exxasens', 'exxasens', None), - (' exxasens ', 'exxasens', None), - ('', '', ValueEmptyError), - (' ' * random.randint(2, 20), '', ValueEmptyError), - ]) - def test_not_empty_rule(self, value, exp_val, exp_err) -> None: - rule = NotEmpty() - if exp_err: - self.assertRaises(exp_err, rule.validate, value) - else: - self.assertEqual(rule.validate(value), exp_val) + try: + rules.validate(value) + except RulesError as e: + self.assertTrue(1, len(e.errors)) + self.assertTrue(isinstance(e.errors[0], expected)) - @parameterized.expand([ - ('fred@web.de', None, ), - ('genial@gmail.com', None, ), - ('test@test.co.uk', None, ), - ('fred', None, ), - ('fred@web', ValueEmailError, ), - ('fred@w@eb.de', ValueEmailError, ), - ('fred@@web.de', ValueEmailError, ), - ('fred@@web.de', ValueEmailError, ), - ('invalid@invalid', ValueEmailError, ), - ]) - def test_is_email_rule(self, value, exp_err) -> None: - self._assert_rule_error(IsEmail(), value, exp_err) + def test_composite_wrong_usage(self): + checkers = [ + Number(), + BoolRule(), + BoolRule({'+'}), + BoolRule({'+'}, {'-'}), + BoolRule({'-'}), + IntRule(), + IntRule(False), + FloatRule(), + FloatRule({','}), + ] + + random.shuffle(checkers) + with self.assertRaises(expected_exception=WrongUsageError): + CompositeRule(*checkers[0:2]) @parameterized.expand([ - ('2021-01-02T03:04:05.450686Z', datetime(2021, 1, 2, 3, 4, 5, 450686)), + # Pattern ( - '2020-12-01T21:31:32.956214+02:00', - datetime(2020, 12, 1, 21, 31, 32, 956214, timezone(timedelta(seconds=7200))), + Pattern(r'^[0-9]*$'), + [ + ['0', '0'], + ['god is an astronaut', ValuePatternError], + [' ', ValuePatternError], + ] ), + # Enum ( - '2020-12-01T21:31:32.956214-02:00', - datetime(2020, 12, 1, 21, 31, 32, 956214, timezone(timedelta(-1, seconds=79200))), + Enum('thievery corporation', 'bonobo'), + [ + ['jimi hendrix', ValueEnumError], + ['thievery corporation', 'thievery corporation'], + ['bonobo', 'bonobo'], + ], + ), + # NotEmpty + (NotEmpty(), [ + ['exxasens', 'exxasens'], + [' exxasens ', 'exxasens'], + ['', ValueEmptyError], + [' ' * random.randint(2, 20), ValueEmptyError], + ]), + # IsEmail + ( + IsEmail(), + [ + ['fred@web.de', 'fred@web.de'], + ['genial@gmail.com', 'genial@gmail.com'], + ['test@test.co.uk', 'test@test.co.uk'], + ['test@test.co.uk', 'test@test.co.uk'], + ['fred', ValueEmailError], + ['fred@web', ValueEmailError], + ['fred@w@eb.de', ValueEmailError], + ['invalid@invalid', ValueEmailError], + ], + ), + # IsDatetimeIsoFormat + ( + IsDatetimeIsoFormat(), + [ + ['2021-01-02T03:04:05.450686Z', datetime(2021, 1, 2, 3, 4, 5, 450686)], + ['2020-12-01T21:31:32.956214+02:00', datetime(2020, 12, 1, 21, 31, 32, + 956214, timezone(timedelta(seconds=7200)))], + ['2020-12-01T21:31:32.956214+02:00', datetime(2020, 12, 1, 21, 31, 32, 956214, + timezone(timedelta(seconds=7200)))], + ['2021-01-02', datetime(2021, 1, 2)], + ['2020-12-01T21:31:41', datetime(2020, 12, 1, 21, 31, 41)], + ['2020-12-01T21', datetime(2020, 12, 1, 21, 0, 0)], + ['2020-12-01T21:30', datetime(2020, 12, 1, 21, 30, 0)], + ['2020-12-01T', ValueDtIsoFormatError], + ['2020-12', ValueDtIsoFormatError], + ['2020', ValueDtIsoFormatError], + ], + ), + # Datetime + (Datetime('%Y-%m-%d'), [['2021-01-02', datetime(2021, 1, 2)]]), + (Datetime('%Y-%m-%d %H:%M:%S'), [['2020-02-03 04:05:06', datetime(2020, 2, 3, 4, 5, 6)]]), + (Datetime('%Y-%m-%d %H:%M:%S'), [['2020-0a-0b 04:05:06', ValueDatetimeError]]), + (Datetime('%Y-%m-%d'), [['2020-01-0z', ValueDatetimeError]]), + # Number + ( + Number(), + [ + [1.0000000000000001e-21, 1.0000000000000001e-21], + [2, 2], + [3.04, 3.04], + ['3o1', NumberError], + ['', NumberError], + [[], NumberError], + [{}, NumberError], + ], + ), + # IntRule + (IntRule(), [[8, 8], ['69', 69]]), + ( + IntRule(False), + [ + ['101', TypeConversionError], + [1.05, TypeConversionError], + [[], TypeConversionError], + [{}, TypeConversionError], + [None, TypeConversionError], + ] + ), + # FloatRule + ( + FloatRule(), + [ + [99.69, 99.69], + [-1.08, -1.08], + ['1.001', TypeConversionError], + [7, TypeConversionError], + [[], TypeConversionError], + [{}, TypeConversionError], + ], + ), + ( + FloatRule({'.', ','}), + [ + ['1000,0001', 1000.0001], + ['1000.0001', 1000.0001], + ['1000-0001', TypeConversionError], + [65, TypeConversionError], + ], + ), + # BoolRule + ( + BoolRule(), + [ + [True, True], + [False, False], + [[], TypeConversionError], + [{}, TypeConversionError], + [1, TypeConversionError], + ['1', TypeConversionError], + [0, TypeConversionError], + ['0', TypeConversionError], + ], + ), + ( + BoolRule({'yes', '+', 1}, {'-', 'no', 0}), + [ + [True, True], + [1, True], + ['yes', True], + ['YeS', True], + ['+', True], + [False, False], + [0, False], + ['no', False], + ['No', False], + ['-', False], + ], ), - ('2021-01-02', datetime(2021, 1, 2)), - ('2020-12-01T21:31:41', datetime(2020, 12, 1, 21, 31, 41)), - ('2020-12-01T21', datetime(2020, 12, 1, 21, 0, 0)), - ('2020-12-01T21:30', datetime(2020, 12, 1, 21, 30, 0)), - ('2020-12-01T', None), - ('2020-12', None), - ('2020', None), - ]) - def test_datetime_iso_format_rule(self, value, exp_dt) -> None: - rule = IsDatetimeIsoFormat() - if exp_dt: - self.assertEqual(exp_dt, rule.validate(value)) - else: - self.assertRaises(ValueDtIsoFormatError, rule.validate, value) - - @parameterized.expand([ - ('2021-01-02', '%Y-%m-%d', datetime(2021, 1, 2), None), - ('2020-02-03 04:05:06', '%Y-%m-%d %H:%M:%S', datetime(2020, 2, 3, 4, 5, 6), None), - ('2020-0a-0b 04:05:06', '%Y-%m-%d %H:%M:%S', None, ValueDatetimeError), - ('2020-01-0z', '%Y-%m-%d', None, ValueDatetimeError), ]) - def test_datetime_rule(self, value, dt_format, dt, err): - rule = Datetime(dt_format) - if err: - self.assertRaises(ValueDatetimeError, rule.validate, value) - else: - self.assertEqual(dt, rule.validate(value)) + def test_rule(self, rule, values): + for value, expected in values: + if type(expected) is type: + self.assertRaises(expected, rule.validate, value) + continue - @parameterized.expand([ - (1.0000000000000001e-21, 1.0000000000000001e-21), - (2, 2), - (3.04, 3.04), - ('3o1', None), - ('', None), - ([], None), - ({}, None), - ]) - def test_number_rule(self, value, expected): - rule = Number() - if expected: self.assertEqual(expected, rule.validate(value)) - else: - self.assertRaises(NumberError, rule.validate, value) + diff --git a/tests/test_validator.py b/tests/test_validator.py index e7053e9..df89267 100755 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -660,3 +660,59 @@ def test_after_params(self, dates, expected): content_type='application/json', ).json self.assertEqual(data, expected) + + +@_app2.route('/issue/83', methods=['POST']) +@validate_params( + JsonParam(dict( + age=[Min(27), IntRule()], + age_as_str=[Min(27), IntRule()], + price=[Min(0.69), FloatRule()], + price_as_str=[Min(0.69), FloatRule({','})], + yes=[BoolRule()], + no=[BoolRule()], + plus=[BoolRule({'+'})], + one=[BoolRule({1})], + zero=[BoolRule(no={0})], + minus=[BoolRule(no={'-'})], + )) +) +def issue_83(valid: ValidRequest): + return flask.jsonify(valid.get_json()) + + +class TestIntFloatBoolRules(TestCase): + def test_issue_83(self): + with _app2.test_client() as client: + data = client.post( + '/issue/83', + data=json.dumps(dict( + age=27, + age_as_str='27', + price=0.69, + price_as_str='0,69', + yes=True, + no=False, + plus='+', + one=1, + zero=0, + minus='-', + )), + content_type='application/json', + ).json + + self.assertEqual( + data, + dict( + age=27, + age_as_str=27, + price=0.69, + price_as_str=0.69, + yes=True, + no=False, + plus=True, + one=True, + zero=False, + minus=False, + ) + )