From eeb54d2e10f5eb0f6deedbc595cc2b5ea0e01a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Sun, 16 Jun 2019 22:12:46 -0500 Subject: [PATCH 1/9] Add method name as argument for validating required fields with callables. --- restea/fields.py | 10 ++++++---- restea/resource.py | 13 ++++++------- tests/test_fields.py | 18 +++++++++--------- tests/test_resource.py | 14 +++++++------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/restea/fields.py b/restea/fields.py index 2f6c456..992ad65 100644 --- a/restea/fields.py +++ b/restea/fields.py @@ -39,7 +39,7 @@ def field_names(self): ''' return set(self.fields.keys()) - def get_required_field_names(self, data): + def get_required_field_names(self, method_name, data): ''' Returns only required field names :returns: required field names (from self.fields) @@ -47,7 +47,7 @@ def get_required_field_names(self, data): ''' def is_required_field(field, data): if callable(field.required): - return field.required(data) + return field.required(method_name, data) else: return field.required @@ -56,9 +56,11 @@ def is_required_field(field, data): if is_required_field(field, data) ) - def validate(self, data): + def validate(self, method_name, data): ''' Validates payload input + :param method_name: name of the method + :type method_name: str :param data: input playload data to be validated :type data: dict :raises restea.fields.FieldSet.Error: field validation failed @@ -74,7 +76,7 @@ def validate(self, data): continue cleaned_data[name] = self.fields[name].validate(value) - for req_field in self.get_required_field_names(cleaned_data): + for req_field in self.get_required_field_names(method_name, cleaned_data): if req_field not in cleaned_data: raise self.Error('Field "{}" is missing'.format(req_field)) diff --git a/restea/resource.py b/restea/resource.py index d71fdb5..55cf434 100644 --- a/restea/resource.py +++ b/restea/resource.py @@ -171,10 +171,12 @@ def _get_method(self, method_name): ) return getattr(type(self), method_name) - def _get_payload(self): + def _get_payload(self, method_name): ''' Returns a validated and parsed payload data for request + :param method_name: name of the method + :type method_name: str :raises restea.errors.BadRequestError: unparseable data :raises restea.errors.BadRequestError: payload is not mappable :raises restea.errors.BadRequestError: validation of fields not passed @@ -197,7 +199,7 @@ def _get_payload(self): ) try: - return self.fields.validate(payload_data) + return self.fields.validate(method_name, payload_data) except fields.FieldSet.Error as e: raise errors.BadRequestError(str(e)) except fields.FieldSet.ConfigurationError as e: @@ -224,16 +226,13 @@ def process(self, *args, **kwargs): if not self._is_valid_formatter: raise errors.BadRequestError('Not recognizable format') - self.payload = self._get_payload() - - self.prepare() - method_name = self._get_method_name(has_iden=bool(args or kwargs)) + self.payload = self._get_payload(method_name) method = self._get_method(method_name) method = self._apply_decorators(method) + self.prepare() response = method(self, *args, **kwargs) - response = self.finish(response) try: diff --git a/tests/test_fields.py b/tests/test_fields.py index 795b4a3..58a4f71 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -49,30 +49,30 @@ def test_field_set_required_fields(): fs, f1, f2 = create_field_set_helper() f1.required = True f2.required = False - assert fs.get_required_field_names({}) == set(['field1']) + assert fs.get_required_field_names('create', {}) == set(['field1']) def test_field_set_required_fields_callable(): fs, f1, f2 = create_field_set_helper() - def foo(data): + def foo(method_name, data): return data.get('field2') == 0 f1.required = foo f2.required = False - assert fs.get_required_field_names({'field2': 0}) == set(['field1']) - assert fs.get_required_field_names({}) == set([]) + assert fs.get_required_field_names('create', {'field2': 0}) == set(['field1']) + assert fs.get_required_field_names('create', {}) == set([]) - f1.required = lambda data: data.get('field2') == 0 + f1.required = lambda method_name, data: data.get('field2') == 0 f2.required = False - assert fs.get_required_field_names({'field2': 0}) == set(['field1']) - assert fs.get_required_field_names({}) == set([]) + assert fs.get_required_field_names('create', {'field2': 0}) == set(['field1']) + assert fs.get_required_field_names('create', {}) == set([]) def test_field_set_validate(): fs, f1, f2 = create_field_set_helper() f1.validate.return_value = 1 f2.validate.return_value = 2 - res = fs.validate({'field1': '1', 'field2': '2', 'field3': 'wrong!'}) + res = fs.validate('create', {'field1': '1', 'field2': '2', 'field3': 'wrong!'}) assert res == {'field1': 1, 'field2': 2} f1.validate.assert_called_with('1') @@ -84,7 +84,7 @@ def test_feild_set_validate_requred_fields_missing(): f1.requred = True with pytest.raises(FieldSet.Error) as e: - fs.validate({'field2': '2'}) + fs.validate('create', {'field2': '2'}) assert 'Field "field1" is missing' in str(e) diff --git a/tests/test_resource.py b/tests/test_resource.py index e58677c..eb67e1e 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -205,7 +205,7 @@ def test_get_payload_should_pass_validation(): resource.fields = mock.Mock() resource.fields.validate.return_value = expected_data - assert resource._get_payload() == expected_data + assert resource._get_payload('edit') == expected_data def test_get_payload_unexpected_data(): @@ -215,7 +215,7 @@ def test_get_payload_unexpected_data(): formatter_mock.unserialize.side_effect = formats.LoadError() with pytest.raises(errors.BadRequestError) as e: - resource._get_payload() + resource._get_payload('edit') assert 'Fail to load the data' in str(e) @@ -226,7 +226,7 @@ def test_get_payload_not_mapable_payload(): formatter_mock.unserialize.return_value = ['item'] with pytest.raises(errors.BadRequestError) as e: - resource._get_payload() + resource._get_payload('edit') assert 'Data should be key -> value structure' in str(e) @@ -243,7 +243,7 @@ def test_get_payload_field_validation_fails(): ) with pytest.raises(errors.BadRequestError) as e: - resource._get_payload() + resource._get_payload('edit') assert field_error_message in str(e) @@ -261,13 +261,13 @@ def test_get_payload_field_misconfigured_fields_fails(): resource.fields.validate.side_effect = conf_error with pytest.raises(errors.ServerError) as e: - resource._get_payload() + resource._get_payload('edit') assert configuration_error_message in str(e) def test_get_payload_field_validation_no_data_empty_payload(): resource, _, _ = create_resource_helper(method='POST') - assert {} == resource._get_payload() + assert {} == resource._get_payload('create') def test_get_payload_validation_no_fields_case_empty_payload(): @@ -275,7 +275,7 @@ def test_get_payload_validation_no_fields_case_empty_payload(): method='PUT', data='data' ) formatter_mock.unserialize.return_value = {'data': 'test'} - assert {} == resource._get_payload() + assert {} == resource._get_payload('edit') @patch.object(formats.JsonFormat, 'serialize') From 6048866ab95e1078eb087ce8a170811a21e24621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Sun, 16 Jun 2019 22:24:21 -0500 Subject: [PATCH 2/9] Fixed code style. --- restea/adapters/djangowrap.py | 2 +- restea/adapters/wheezywebwrap.py | 2 +- restea/fields.py | 11 ++++++++--- tests/test_fields.py | 15 ++++++++++----- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/restea/adapters/djangowrap.py b/restea/adapters/djangowrap.py index 36dbbe9..5613af7 100644 --- a/restea/adapters/djangowrap.py +++ b/restea/adapters/djangowrap.py @@ -65,7 +65,7 @@ def prepare_response(self, content, status_code, content_type, headers): response[name] = value return response - def get_routes(self, path='', iden_format='(?P\w+)'): + def get_routes(self, path='', iden_format=r'(?P\w+)'): ''' Prepare routes for the given REST resource diff --git a/restea/adapters/wheezywebwrap.py b/restea/adapters/wheezywebwrap.py index fe8ed99..2dd7b81 100644 --- a/restea/adapters/wheezywebwrap.py +++ b/restea/adapters/wheezywebwrap.py @@ -78,7 +78,7 @@ def prepare_response(self, content, status_code, content_type, headers): response.headers.append((name, value)) return response - def get_routes(self, path='', iden_format='(?P\w+)'): + def get_routes(self, path='', iden_format=r'(?P\w+)'): ''' Prepare routes for the given REST resource diff --git a/restea/fields.py b/restea/fields.py index 992ad65..1d07feb 100644 --- a/restea/fields.py +++ b/restea/fields.py @@ -76,7 +76,10 @@ def validate(self, method_name, data): continue cleaned_data[name] = self.fields[name].validate(value) - for req_field in self.get_required_field_names(method_name, cleaned_data): + required_field_names = self.get_required_field_names( + method_name, cleaned_data + ) + for req_field in required_field_names: if req_field not in cleaned_data: raise self.Error('Field "{}" is missing'.format(req_field)) @@ -279,8 +282,10 @@ class Email(String): Email implements field validation for emails ''' error_message = '"%s" is not a valid email' - pattern = r'^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*' \ - '(\.[a-z]{2,16})$' + pattern = ( + r'^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*' + r'(\.[a-z]{2,16})$' + ) def _validate_field(self, field_value): if not re.match(self.pattern, field_value, re.IGNORECASE): diff --git a/tests/test_fields.py b/tests/test_fields.py index 58a4f71..6cdc9c2 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -59,20 +59,25 @@ def foo(method_name, data): return data.get('field2') == 0 f1.required = foo f2.required = False - assert fs.get_required_field_names('create', {'field2': 0}) == set(['field1']) - assert fs.get_required_field_names('create', {}) == set([]) + required_field_names = fs.get_required_field_names('create', {'field2': 0}) + assert required_field_names == set(['field1']) + required_field_names = fs.get_required_field_names('create', {}) + assert required_field_names == set([]) f1.required = lambda method_name, data: data.get('field2') == 0 f2.required = False - assert fs.get_required_field_names('create', {'field2': 0}) == set(['field1']) - assert fs.get_required_field_names('create', {}) == set([]) + required_field_names = fs.get_required_field_names('create', {'field2': 0}) + assert required_field_names == set(['field1']) + required_field_names = fs.get_required_field_names('create', {}) + assert required_field_names == set([]) def test_field_set_validate(): fs, f1, f2 = create_field_set_helper() f1.validate.return_value = 1 f2.validate.return_value = 2 - res = fs.validate('create', {'field1': '1', 'field2': '2', 'field3': 'wrong!'}) + payload = {'field1': '1', 'field2': '2', 'field3': 'wrong!'} + res = fs.validate('create', payload) assert res == {'field1': 1, 'field2': 2} f1.validate.assert_called_with('1') From b15ab6f8b6112a61c0bccd291a8e9c22ac5e5505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Sun, 16 Jun 2019 22:32:21 -0500 Subject: [PATCH 3/9] Bump 0.3.9 version. --- restea/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/restea/__init__.py b/restea/__init__.py index d93912e..6a49b24 100644 --- a/restea/__init__.py +++ b/restea/__init__.py @@ -1 +1 @@ -__version__ = '0.3.7' +__version__ = '0.3.9' diff --git a/setup.py b/setup.py index 9a7a316..2294ed7 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='restea', packages=['restea', 'restea.adapters'], - version='0.3.8', + version='0.3.9', description='Simple RESTful server toolkit', long_description=readme_content, author='Walery Jadlowski', From 7c2ee58096d04fee5bdf7fe217b4c24e6741b2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 02:07:00 -0500 Subject: [PATCH 4/9] Minor edit. --- restea/adapters/djangowrap.py | 2 +- restea/adapters/wheezywebwrap.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/restea/adapters/djangowrap.py b/restea/adapters/djangowrap.py index 5613af7..36dbbe9 100644 --- a/restea/adapters/djangowrap.py +++ b/restea/adapters/djangowrap.py @@ -65,7 +65,7 @@ def prepare_response(self, content, status_code, content_type, headers): response[name] = value return response - def get_routes(self, path='', iden_format=r'(?P\w+)'): + def get_routes(self, path='', iden_format='(?P\w+)'): ''' Prepare routes for the given REST resource diff --git a/restea/adapters/wheezywebwrap.py b/restea/adapters/wheezywebwrap.py index 2dd7b81..fe8ed99 100644 --- a/restea/adapters/wheezywebwrap.py +++ b/restea/adapters/wheezywebwrap.py @@ -78,7 +78,7 @@ def prepare_response(self, content, status_code, content_type, headers): response.headers.append((name, value)) return response - def get_routes(self, path='', iden_format=r'(?P\w+)'): + def get_routes(self, path='', iden_format='(?P\w+)'): ''' Prepare routes for the given REST resource From db057697e69d859bafe34e8a93563075f004f8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 02:47:52 -0500 Subject: [PATCH 5/9] Revert "Minor edit." This reverts commit 7c2ee58096d04fee5bdf7fe217b4c24e6741b2bb. --- restea/adapters/djangowrap.py | 2 +- restea/adapters/wheezywebwrap.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/restea/adapters/djangowrap.py b/restea/adapters/djangowrap.py index 36dbbe9..5613af7 100644 --- a/restea/adapters/djangowrap.py +++ b/restea/adapters/djangowrap.py @@ -65,7 +65,7 @@ def prepare_response(self, content, status_code, content_type, headers): response[name] = value return response - def get_routes(self, path='', iden_format='(?P\w+)'): + def get_routes(self, path='', iden_format=r'(?P\w+)'): ''' Prepare routes for the given REST resource diff --git a/restea/adapters/wheezywebwrap.py b/restea/adapters/wheezywebwrap.py index fe8ed99..2dd7b81 100644 --- a/restea/adapters/wheezywebwrap.py +++ b/restea/adapters/wheezywebwrap.py @@ -78,7 +78,7 @@ def prepare_response(self, content, status_code, content_type, headers): response.headers.append((name, value)) return response - def get_routes(self, path='', iden_format='(?P\w+)'): + def get_routes(self, path='', iden_format=r'(?P\w+)'): ''' Prepare routes for the given REST resource From e5abddd3620566f5c2fff49b858cb1f5801ea52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 02:51:32 -0500 Subject: [PATCH 6/9] Fixed original request calculation. --- restea/adapters/base.py | 8 ++++---- restea/adapters/flaskwrap.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/restea/adapters/base.py b/restea/adapters/base.py index 37397f6..baa4060 100644 --- a/restea/adapters/base.py +++ b/restea/adapters/base.py @@ -49,23 +49,23 @@ def prepare_response(self, content, status_code, content_type, headers): ''' raise NotImplementedError - def get_original_request(self, *args, **kwargs): + def get_original_request(self, request, *args, **kwargs): ''' Returns the original request object. This method receives all arguments that the `wrap_request` method receives and return the first argument as is commonly received. ''' - return args[0] + return request - def wrap_request(self, *args, **kwargs): + def wrap_request(self, request, *args, **kwargs): ''' Prepares data and pass control to `restea.Resource` object :returns: Response object for corresponding framework ''' data_format, kwargs = self._get_format_name(kwargs) formatter = formats.get_formatter(data_format) - original_request = self.get_original_request(*args, **kwargs) + original_request = self.get_original_request(request, *args, **kwargs) if not self.request_wrapper_class: raise RuntimeError( diff --git a/restea/adapters/flaskwrap.py b/restea/adapters/flaskwrap.py index b9d0650..bb9848b 100644 --- a/restea/adapters/flaskwrap.py +++ b/restea/adapters/flaskwrap.py @@ -62,7 +62,7 @@ def app(self): ''' return flask.current_app - def get_original_request(self, *args, **kwargs): + def get_original_request(self, request, *args, **kwargs): return flask.request def prepare_response(self, content, status_code, content_type, headers): From f280e26392fbac2063cdfc2f502bed7f2809ea82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 03:19:37 -0500 Subject: [PATCH 7/9] Add empty dict as headers for backward compatibility. --- restea/adapters/base.py | 4 ++++ restea/resource.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/restea/adapters/base.py b/restea/adapters/base.py index baa4060..829c91a 100644 --- a/restea/adapters/base.py +++ b/restea/adapters/base.py @@ -78,6 +78,10 @@ def wrap_request(self, request, *args, **kwargs): ) response_tuple = resource.dispatch(*args, **kwargs) + if len(response_tuple) == 3: + # For backward compatibility, it adds an empty dict as headers + response_tuple += ({},) + return self.prepare_response(*response_tuple) diff --git a/restea/resource.py b/restea/resource.py index 55cf434..75d5272 100644 --- a/restea/resource.py +++ b/restea/resource.py @@ -245,7 +245,8 @@ def dispatch(self, *args, **kwargs): Dispatches the request and handles exception to return data, status and content type - :returns: 3 element tuple: result, HTTP status code and content type + :returns: 4-element tuple: result, HTTP status code, content type, and + headers :rtype: tuple ''' try: From 8f8ab5c0759ef98a92b66b7bbabd3e64ea6df033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 09:16:23 -0500 Subject: [PATCH 8/9] Fixed request and arguments resolution. --- restea/adapters/base.py | 15 +++++++++------ restea/adapters/flaskwrap.py | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/restea/adapters/base.py b/restea/adapters/base.py index 829c91a..48960ed 100644 --- a/restea/adapters/base.py +++ b/restea/adapters/base.py @@ -49,23 +49,26 @@ def prepare_response(self, content, status_code, content_type, headers): ''' raise NotImplementedError - def get_original_request(self, request, *args, **kwargs): + def split_request_and_arguments(self, *args, **kwargs): ''' - Returns the original request object. + Hook to return the original request object and arguments This method receives all arguments that the `wrap_request` method - receives and return the first argument as is commonly received. + receives and return the first argument as the request object by + default which is commonly received in that order. + Override this method in your subclass wrapper if the behavior is + different for your framework. ''' - return request + return args[0], args[1:], kwargs - def wrap_request(self, request, *args, **kwargs): + def wrap_request(self, *args, **kwargs): ''' Prepares data and pass control to `restea.Resource` object :returns: Response object for corresponding framework ''' data_format, kwargs = self._get_format_name(kwargs) formatter = formats.get_formatter(data_format) - original_request = self.get_original_request(request, *args, **kwargs) + original_request, args, kwargs = self.split_request_and_arguments(*args, **kwargs) if not self.request_wrapper_class: raise RuntimeError( diff --git a/restea/adapters/flaskwrap.py b/restea/adapters/flaskwrap.py index bb9848b..c2169e8 100644 --- a/restea/adapters/flaskwrap.py +++ b/restea/adapters/flaskwrap.py @@ -62,8 +62,8 @@ def app(self): ''' return flask.current_app - def get_original_request(self, request, *args, **kwargs): - return flask.request + def split_request_and_arguments(self, *args, **kwargs): + return flask.request, args, kwargs def prepare_response(self, content, status_code, content_type, headers): response = flask.Response( From 17d7e191a9e95a0a1c30fb609f034232a9931133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Puente-Sarr=C3=ADn?= Date: Mon, 17 Jun 2019 09:22:57 -0500 Subject: [PATCH 9/9] Code style. --- restea/adapters/base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/restea/adapters/base.py b/restea/adapters/base.py index 48960ed..3ad96d5 100644 --- a/restea/adapters/base.py +++ b/restea/adapters/base.py @@ -51,7 +51,7 @@ def prepare_response(self, content, status_code, content_type, headers): def split_request_and_arguments(self, *args, **kwargs): ''' - Hook to return the original request object and arguments + Hook to return the original request object and arguments. This method receives all arguments that the `wrap_request` method receives and return the first argument as the request object by @@ -68,7 +68,9 @@ def wrap_request(self, *args, **kwargs): ''' data_format, kwargs = self._get_format_name(kwargs) formatter = formats.get_formatter(data_format) - original_request, args, kwargs = self.split_request_and_arguments(*args, **kwargs) + original_request, args, kwargs = self.split_request_and_arguments( + *args, **kwargs + ) if not self.request_wrapper_class: raise RuntimeError(