From 00d8acc16da6ad2785fd3000107cee470d1cb91f Mon Sep 17 00:00:00 2001 From: Danila Ganchar Date: Fri, 21 Jul 2023 16:59:03 +0300 Subject: [PATCH 1/2] #82 required JsonParams --- .gitignore | 4 + flask_request_validator/exceptions.py | 8 ++ flask_request_validator/nested_json.py | 40 ++++-- setup.py | 2 +- tests/test_nested_json.py | 170 ++++++++----------------- tests/test_validator.py | 129 +++++++++++++++++-- 6 files changed, 210 insertions(+), 143 deletions(-) diff --git a/.gitignore b/.gitignore index 5d1d33b..cfd4097 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,7 @@ ENV/ # Rope project settings .ropeproject .idea/ + +# debug files +debug.py +app.py diff --git a/flask_request_validator/exceptions.py b/flask_request_validator/exceptions.py index 265c184..c4e658b 100755 --- a/flask_request_validator/exceptions.py +++ b/flask_request_validator/exceptions.py @@ -154,6 +154,14 @@ def __init__(self, errors: List[Any]) -> None: self.errors = errors +class MissingJsonKeyError(RuleError): + def __init__(self, key: str) -> None: + self.key = key + + def __str__(self) -> str: + return 'key is required' + + class RulesError(RequestError): def __init__(self, *args: RuleError): self.errors = args diff --git a/flask_request_validator/nested_json.py b/flask_request_validator/nested_json.py index c94f738..53ad359 100644 --- a/flask_request_validator/nested_json.py +++ b/flask_request_validator/nested_json.py @@ -4,7 +4,10 @@ JsonError, RequiredJsonKeyError, JsonListItemTypeError, - RulesError, JsonListExpectedError, JsonDictExpectedError, + RulesError, + JsonListExpectedError, + JsonDictExpectedError, + MissingJsonKeyError, ) from .rules import CompositeRule, AbstractRule @@ -46,6 +49,17 @@ def _check_list_item_type(self, nested: 'JsonParam', value: Any): if isinstance(nested.rules_map, dict) and not isinstance(value, dict): raise JsonListItemTypeError() + def _is_missing_json_key(self, key: str, value: Dict, nested: 'JsonParam'): + rules = nested.rules_map.get(key) + if isinstance(rules, JsonParam) and not rules.required: + return + + try: + if key not in value: + raise MissingJsonKeyError(key) + except MissingJsonKeyError as error: + raise RulesError(error) + def _validate_list( self, value: Union[Dict, List], @@ -101,18 +115,26 @@ def _validate_dict( errors: List[JsonError], ) -> Tuple[Any, List[JsonError], Dict[str, RulesError]]: err = dict() - for key, rules in nested.rules_map.items(): - if key not in value: - continue - elif isinstance(rules, JsonParam): - new_val, errors = self.validate(value[key], rules, depth + [key], errors) - continue + for key, rules in nested.rules_map.items(): try: - new_val = rules.validate(value[key]) - value[key] = new_val + self._is_missing_json_key(key, value, nested) except RulesError as e: err[key] = e + continue + + key_value = value.get(key) + if isinstance(rules, JsonParam): + if key_value is None and not nested.rules_map[key].required: + continue + + new_val, errors = self.validate(key_value, rules, depth + [key], errors) + else: + try: + new_val = rules.validate(key_value) + value[key] = new_val + except RulesError as e: + err[key] = e return value, errors, err diff --git a/setup.py b/setup.py index c4d10cd..56f2c76 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='flask_request_validator', - version='4.2.1', + version='4.2.2', description='Flask request data validation', long_description=long_description, url='https://github.com/d-ganchar/flask_request_validator', diff --git a/tests/test_nested_json.py b/tests/test_nested_json.py index 49e2e9f..fe100b8 100644 --- a/tests/test_nested_json.py +++ b/tests/test_nested_json.py @@ -3,8 +3,8 @@ from parameterized import parameterized -from flask_request_validator.exceptions import * from flask_request_validator import JsonParam, Enum, CompositeRule, Min, Max, IsEmail, Number, MinLength +from flask_request_validator.exceptions import * class TestJsonParam(unittest.TestCase): @@ -59,62 +59,14 @@ class TestJsonParam(unittest.TestCase): 'meta': {'buildings': {'warehouses': {'small': {'count': 100, }, 'large': 0, }, }, }, }, [ - [ - ['root', 'meta', 'buildings', 'warehouses', 'small'], - {'count': [ValueMaxError]}, - ], - [ - ['root', 'meta', 'buildings', 'warehouses'], - {'large': [ValueMinError]}, - ], - [ - ['root', 'meta'], - {'description': RequiredJsonKeyError}, - ], - [ - ['root'], - {'street': [ValueEnumError], }, - ], + "(['root', 'meta', 'buildings', 'warehouses', 'small']," + " {'count': RulesError(ValueMaxError(99, True))}, False)", + "(['root', 'meta', 'buildings', 'warehouses']," + " {'large': RulesError(ValueMinError(1, True))}, False)", + "(['root', 'meta'], {'description': RulesError(MissingJsonKeyError('description'))}, False)", + "(['root'], {'street': RulesError(ValueEnumError(('Jakuba Kolasa',)))}, False)", ], ), - # valid - ( - DICT_SCHEMA, - { - 'country': 'Belarus', - 'city': 'Minsk', - 'street': 'Jakuba Kolasa', - 'meta': { - 'buildings': { - 'warehouses': { - 'small': {'count': 99, }, - 'large': 1, - }, - }, - 'description': { - 'color': 'green', - }, - }, - }, - {}, - ) - ]) - def test_dict(self, param: JsonParam, data, exp): - value, errors = param.validate(data) - for ix, json_error in enumerate(errors): # type: list, JsonError - self.assertTrue(isinstance(json_error, JsonError)) - exp_depth, epx_errors_map = exp[ix] # type: list, dict - self.assertListEqual(json_error.depth, exp_depth) - for key, error in json_error.errors.items(): - if isinstance(error, RulesError): - self.assertEqual(len(error.errors), len(epx_errors_map)) - for ix_rule, rule_err in enumerate(error.errors): - self.assertTrue(isinstance(rule_err, epx_errors_map[key][0])) - else: - self.assertTrue(isinstance(error, epx_errors_map[key])) - - @parameterized.expand([ - # invalid ( LIST_SCHEMA, { @@ -138,31 +90,39 @@ def test_dict(self, param: JsonParam, data, exp): }, }, [ - [ - ['root', 'person', 'info', 'contacts', 'phones'], - { - 2: JsonListItemTypeError, - 3: JsonListItemTypeError, - }, - ], - [ - ['root', 'person', 'info', 'contacts', 'networks'], - { - 1: {'name': [ValueEnumError], }, - 2: {'name': [ValueEnumError], }, - }, - ], - [ - ['root', 'person', 'info', 'contacts', 'emails'], - { - 0: JsonListItemTypeError, - 1: JsonListItemTypeError, - 2: [ValueEmailError], - }, - ], + "(['root', 'person', 'info', 'contacts', 'phones'], " + "{2: JsonListItemTypeError(False), 3: JsonListItemTypeError(False)}, True)", + + "(['root', 'person', 'info', 'contacts', 'networks'], " + "{1: {'name': RulesError(ValueEnumError(('facebook', 'telegram')))}, " + "2: {'name': RulesError(ValueEnumError(('facebook', 'telegram')))}}, True)", + + "(['root', 'person', 'info', 'contacts', 'emails'], " + "{0: JsonListItemTypeError(False), 1: JsonListItemTypeError(False), " + "2: RulesError(ValueEmailError())}, True)", ], ), # valid + ( + DICT_SCHEMA, + { + 'country': 'Belarus', + 'city': 'Minsk', + 'street': 'Jakuba Kolasa', + 'meta': { + 'buildings': { + 'warehouses': { + 'small': {'count': 99, }, + 'large': 1, + }, + }, + 'description': { + 'color': 'green', + }, + }, + }, + {}, + ), ( LIST_SCHEMA, { @@ -184,26 +144,13 @@ def test_dict(self, param: JsonParam, data, exp): [], ), ]) - def test_list(self, param: JsonParam, data, exp): - value, errors = param.validate(data) - self.assertEqual(len(exp), len(errors)) - for err_ix, json_er in enumerate(errors): # type: int, JsonError - exp_err = exp[err_ix] - exp_rule_err = exp_err[1] - self.assertListEqual(json_er.depth, exp_err[0]) - self.assertEqual(len(exp_err[1]), len(json_er.errors)) - for rules_ix, rules_err in exp_rule_err.items(): - json_rules = json_er.errors[rules_ix] # type: dict or list or RulesError - if isinstance(exp_rule_err[rules_ix], list): - self.assertTrue(isinstance(json_rules, RulesError)) - for k, rule_err in enumerate(json_rules.errors): - self.assertTrue(isinstance(rule_err, exp_rule_err[rules_ix][k])) - else: - if isinstance(rules_err, dict): - self.assertTrue(len(json_rules), len(rules_err)) - else: - # RulesError - self.assertTrue(isinstance(json_er.errors[rules_ix], rules_err)) + def test_validate(self, param: JsonParam, data, exp): + value, errors = param.validate(deepcopy(data)) + self.assertEqual(len(errors), len(exp)) + + for ix, json_error in enumerate(exp): # type: int, List[list, dict] + str_error = str(errors[ix]) + self.assertEqual(json_error, str_error) @parameterized.expand([ ( @@ -245,28 +192,13 @@ def test_root_list_invalid(self): {'age': 15, 'name': 'good'}, ]) - self.assertEqual(1, len(errors)) - self.assertTrue(isinstance(errors[0], JsonError)) - error = errors[0] - self.assertListEqual(['root'], error.depth) - self.assertTrue(2, len(error.errors)) - self.assertTrue(error.errors[0], 1) - self.assertTrue(error.errors[1], 2) + self.assertEqual( + "[JsonError(['root'], " + "{0: {'age': RulesError(NumberError())}, 1: " + "{'age': RulesError(NumberError()), 'name': RulesError(ValueMinLengthError(1))}}, True)]", + str(errors) + ) - self.assertTrue(isinstance(error.errors[0]['age'].errors[0], NumberError)) - self.assertTrue(isinstance(error.errors[1]['age'].errors[0], NumberError)) - self.assertTrue(isinstance(error.errors[1]['name'].errors[0], ValueMinLengthError)) # invalid type - dict instead list _, errors = param.validate({'age': 18, 'name': 'test'}) - self.assertEqual(1, len(errors)) - self.assertListEqual(['root'], errors[0].depth) - self.assertTrue(isinstance(errors[0], JsonListExpectedError)) - # invalid type - nested string instead list - _, errors = param.validate([ - {'age': 27, 'name': 'test'}, - {'age': 15, 'name': 'good', 'tags': 'bad_type'}, - ]) - - self.assertEqual(1, len(errors)) - self.assertListEqual(['root', 'tags'], errors[0].depth) - self.assertTrue(isinstance(errors[0], JsonListExpectedError)) + self.assertEqual("[JsonListExpectedError(['root'])]", str(errors)) diff --git a/tests/test_validator.py b/tests/test_validator.py index 2356dcb..e7053e9 100755 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -375,6 +375,21 @@ def home(valid: ValidRequest): return flask.jsonify({'json': valid.get_json()}) +@_app2.route('/issue/82', methods=['POST']) +@validate_params( + JsonParam( + { + 'namespace': [MinLength(1), MaxLength(255)], + 'key': [MinLength(1), MaxLength(255)], + 'value': [MaxLength(255)], + }, + as_list=True, + ) +) +def issue_82(valid: ValidRequest): + return flask.jsonify({'json': valid.get_json()}) + + class TestNestedJson(TestCase): maxDiff = 2000 @@ -432,8 +447,11 @@ class TestNestedJson(TestCase): 'bands': [ { 'name': 'Metallica', - 'details': {'details': 'Los Angeles, California, U.S.', - 'status': 'active'}, + 'details': { + 'details': 'Los Angeles, California, U.S.', + 'description': 'very long description', + 'status': 'active', + }, 'persons': [ {'name': 'James Hetfield'}, {'name': 'Lars Ulrich'}, @@ -443,7 +461,11 @@ class TestNestedJson(TestCase): }, { 'name': 'AC/DC', - 'details': {'details': 'Sydney, Australia', 'status': 'active'}, + 'details': { + 'details': 'Sydney, Australia', + 'status': 'active', + 'description': 'positive', + }, 'persons': [ {'name': 'Angus Young'}, {'name': 'Stevie Young'}, @@ -459,17 +481,35 @@ class TestNestedJson(TestCase): 'json': { 'island': 'valid', 'iso': '2021-01-02', 'music': { 'bands': [ - {'details': {'details': 'Los Angeles, California, U.S.', - 'status': 'active'}, - 'name': 'Metallica', - 'persons': [{'name': 'James Hetfield'}, {'name': 'Lars Ulrich'}, - {'name': 'Kirk Hammett'}, {'name': 'Robert Trujillo'}]}, - {'details': {'details': 'Sydney, Australia', 'status': 'active'}, - 'name': 'AC/DC', - 'persons': [{'name': 'Angus Young'}, {'name': 'Stevie Young'}, - {'name': 'Brian Johnson'}, {'name': 'Phil Rudd'}, - {'name': 'Cliff Williams'}] - } + { + 'name': 'Metallica', + 'details': { + 'details': 'Los Angeles, California, U.S.', + 'description': 'very long description', + 'status': 'active', + }, + 'persons': [ + {'name': 'James Hetfield'}, + {'name': 'Lars Ulrich'}, + {'name': 'Kirk Hammett'}, + {'name': 'Robert Trujillo'}, + ] + }, + { + 'name': 'AC/DC', + 'details': { + 'details': 'Sydney, Australia', + 'status': 'active', + 'description': 'positive', + }, + 'persons': [ + {'name': 'Angus Young'}, + {'name': 'Stevie Young'}, + {'name': 'Brian Johnson'}, + {'name': 'Phil Rudd'}, + {'name': 'Cliff Williams'}, + ], + }, ] } } @@ -483,6 +523,67 @@ def test_json_route_with_error_formatter(self, data, expected, status): self.assertEqual(response.status, status) self.assertEqual(response.json, expected) + @parameterized.expand([ + ( + [{'key': 'testKey', 'value': 'testValue'}], + [{ + 'errors': [{'list_items': {'0': {'namespace': 'key is required'}}, 'path': 'root'}], + 'message': 'invalid JSON parameters', + }], + ), + ( + [{}, {'unknown_field': 'value'}], + [ + { + 'errors': [ + { + 'list_items': { + '0': { + 'key': 'key is required', + 'namespace': 'key is required', + 'value': 'key is required', + }, + '1': { + 'key': 'key is required', + 'namespace': 'key is required', + 'value': 'key is required', + }, + }, + 'path': 'root', + }, + ], + 'message': 'invalid JSON parameters', + }, + ], + ) + ]) + def test_issue_82_negative(self, data, expected): + with _app2.test_client() as client: + response = client.post('/issue/82', data=json.dumps(data), content_type='application/json') + self.assertEqual(response.status, '400 BAD REQUEST') + self.assertEqual(response.json, expected) + + def test_issue_82_positive(self): + with _app2.test_client() as client: + response = client.post( + '/issue/82', + data=json.dumps([ + dict(namespace='movies', key='science fiction', value='stranger things'), + dict(namespace='music', key='downtempo,chill-out,dub,lounge', value='thievery corporation'), + ]), + content_type='application/json', + ) + + self.assertEqual(response.status, '200 OK') + self.assertEqual( + response.json, + dict( + json=[ + dict(key='science fiction', namespace='movies', value='stranger things'), + dict(key='downtempo,chill-out,dub,lounge', namespace='music', value='thievery corporation'), + ] + )) + class ExampleAfterParam(AbstractAfterParam): def validate(self, value: ValidRequest) -> Any: From 110e18640e88bdeb98fdd8b92bc6f5df1a17ad8f Mon Sep 17 00:00:00 2001 From: Danila Ganchar Date: Fri, 21 Jul 2023 17:08:37 +0300 Subject: [PATCH 2/2] py3.8 support deprecated As of 2021-12-23, Python 3.6 has reached the end-of-life phase of its life cycle --- .travis.yml | 1 - setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index fa1071d..99833f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python dist: focal python: - - "3.6" - "3.7" - "3.8" - "3.9" diff --git a/setup.py b/setup.py index 56f2c76..08fe544 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,6 @@ classifiers=[ 'Development Status :: 5 - Production/Stable', 'Framework :: Flask', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9',