Browse Source

Merge pull request #44 from algoo/feature/43__add_a_feature_to_manage_exceptions_globally

Damien Accorsi 6 years ago
parent
commit
f4bac97ccb
No account linked to committer's email

+ 114 - 0
hapic/context.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import json
2
 import typing
3
 import typing
3
 
4
 
4
 from hapic.error import ErrorBuilderInterface
5
 from hapic.error import ErrorBuilderInterface
106
         """
107
         """
107
         raise NotImplementedError()
108
         raise NotImplementedError()
108
 
109
 
110
+    def handle_exception(
111
+        self,
112
+        exception_class: typing.Type[Exception],
113
+        http_code: int,
114
+    ) -> None:
115
+        """
116
+        Enable management of this exception during execution of views. If this
117
+        exception caught, an http response will be returned with this http
118
+        code.
119
+        :param exception_class: Exception class to catch
120
+        :param http_code: HTTP code to use in response if exception caught
121
+        """
122
+        raise NotImplementedError()
123
+
124
+    def handle_exceptions(
125
+        self,
126
+        exception_classes: typing.List[typing.Type[Exception]],
127
+        http_code: int,
128
+    ) -> None:
129
+        """
130
+        Enable management of these exceptions during execution of views. If
131
+        this exception caught, an http response will be returned with this http
132
+        code.
133
+        :param exception_classes: Exception classes to catch
134
+        :param http_code: HTTP code to use in response if exception caught
135
+        """
136
+        raise NotImplementedError()
137
+
138
+
139
+class HandledException(object):
140
+    """
141
+    Representation of an handled exception with it's http code
142
+    """
143
+    def __init__(
144
+        self,
145
+        exception_class: typing.Type[Exception],
146
+        http_code: int = 500,
147
+    ):
148
+        self.exception_class = exception_class
149
+        self.http_code = http_code
150
+
109
 
151
 
110
 class BaseContext(ContextInterface):
152
 class BaseContext(ContextInterface):
111
     def get_default_error_builder(self) -> ErrorBuilderInterface:
153
     def get_default_error_builder(self) -> ErrorBuilderInterface:
112
         """ see hapic.context.ContextInterface#get_default_error_builder"""
154
         """ see hapic.context.ContextInterface#get_default_error_builder"""
113
         return self.default_error_builder
155
         return self.default_error_builder
156
+
157
+    def handle_exception(
158
+        self,
159
+        exception_class: typing.Type[Exception],
160
+        http_code: int,
161
+    ) -> None:
162
+        self._add_exception_class_to_catch(exception_class, http_code)
163
+
164
+    def handle_exceptions(
165
+        self,
166
+        exception_classes: typing.List[typing.Type[Exception]],
167
+        http_code: int,
168
+    ) -> None:
169
+        for exception_class in exception_classes:
170
+            self._add_exception_class_to_catch(exception_class, http_code)
171
+
172
+    def handle_exceptions_decorator_builder(
173
+        self,
174
+        func: typing.Callable[..., typing.Any],
175
+    ) -> typing.Callable[..., typing.Any]:
176
+        """
177
+        Return a decorator who catch exceptions raised during given function
178
+        execution and return a response built by the default error builder.
179
+
180
+        :param func: decorated function
181
+        :return: the decorator
182
+        """
183
+        def decorator(*args, **kwargs):
184
+            try:
185
+                return func(*args, **kwargs)
186
+            except Exception as exc:
187
+                # Reverse list to read first user given exception before
188
+                # the hapic default Exception catch
189
+                handled_exceptions = reversed(
190
+                    self._get_handled_exception_class_and_http_codes(),
191
+                )
192
+                for handled_exception in handled_exceptions:
193
+                    # TODO BS 2018-05-04: How to be attentive to hierarchy ?
194
+                    if isinstance(exc, handled_exception.exception_class):
195
+                        error_builder = self.get_default_error_builder()
196
+                        error_body = error_builder.build_from_exception(exc)
197
+                        return self.get_response(
198
+                            json.dumps(error_body),
199
+                            handled_exception.http_code,
200
+                        )
201
+                raise exc
202
+
203
+        return decorator
204
+
205
+    def _get_handled_exception_class_and_http_codes(
206
+        self,
207
+    ) -> typing.List[HandledException]:
208
+        """
209
+        :return: A list of tuple where: thirst item of tuple is a exception
210
+        class and second tuple item is a http code. This list will be used by
211
+        `handle_exceptions_decorator_builder` decorator to catch exceptions.
212
+        """
213
+        raise NotImplementedError()
214
+
215
+    def _add_exception_class_to_catch(
216
+        self,
217
+        exception_class: typing.Type[Exception],
218
+        http_code: int,
219
+    ) -> None:
220
+        """
221
+        Add an exception class to catch and matching http code. Will be used by
222
+        `handle_exceptions_decorator_builder` decorator to catch exceptions.
223
+        :param exception_class: exception class to catch
224
+        :param http_code: http code to use if this exception catched
225
+        :return:
226
+        """
227
+        raise NotImplementedError()

+ 30 - 0
hapic/ext/bottle/context.py View File

12
 from multidict import MultiDict
12
 from multidict import MultiDict
13
 
13
 
14
 from hapic.context import BaseContext
14
 from hapic.context import BaseContext
15
+from hapic.context import HandledException
15
 from hapic.context import RouteRepresentation
16
 from hapic.context import RouteRepresentation
16
 from hapic.decorator import DecoratedController
17
 from hapic.decorator import DecoratedController
17
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
18
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
33
         app: bottle.Bottle,
34
         app: bottle.Bottle,
34
         default_error_builder: ErrorBuilderInterface=None,
35
         default_error_builder: ErrorBuilderInterface=None,
35
     ):
36
     ):
37
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
38
+        self._exceptions_handler_installed = False
36
         self.app = app
39
         self.app = app
37
         self.default_error_builder = \
40
         self.default_error_builder = \
38
             default_error_builder or DefaultErrorBuilder()  # FDV
41
             default_error_builder or DefaultErrorBuilder()  # FDV
134
         if isinstance(response, bottle.HTTPResponse):
137
         if isinstance(response, bottle.HTTPResponse):
135
             return True
138
             return True
136
         return False
139
         return False
140
+
141
+    def _add_exception_class_to_catch(
142
+        self,
143
+        exception_class: typing.Type[Exception],
144
+        http_code: int,
145
+    ) -> None:
146
+        if not self._exceptions_handler_installed:
147
+            self._install_exceptions_handler()
148
+
149
+        self._handled_exceptions.append(
150
+            HandledException(exception_class, http_code),
151
+        )
152
+
153
+    def _install_exceptions_handler(self) -> None:
154
+        """
155
+        Setup the bottle app to enable exception catching with internal
156
+        hapic exception catcher.
157
+        """
158
+        self.app.install(self.handle_exceptions_decorator_builder)
159
+
160
+    def _get_handled_exception_class_and_http_codes(
161
+        self,
162
+    ) -> typing.List[HandledException]:
163
+        """
164
+        See hapic.context.BaseContext#_get_handled_exception_class_and_http_codes  # nopep8
165
+        """
166
+        return self._handled_exceptions

+ 8 - 0
hapic/ext/flask/context.py View File

33
         app: Flask,
33
         app: Flask,
34
         default_error_builder: ErrorBuilderInterface=None,
34
         default_error_builder: ErrorBuilderInterface=None,
35
     ):
35
     ):
36
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
36
         self.app = app
37
         self.app = app
37
         self.default_error_builder = \
38
         self.default_error_builder = \
38
             default_error_builder or DefaultErrorBuilder()  # FDV
39
             default_error_builder or DefaultErrorBuilder()  # FDV
157
         )
158
         )
158
         def api_doc(path):
159
         def api_doc(path):
159
             return send_from_directory(directory_path, path)
160
             return send_from_directory(directory_path, path)
161
+
162
+    def _add_exception_class_to_catch(
163
+        self,
164
+        exception_class: typing.Type[Exception],
165
+        http_code: int,
166
+    ) -> None:
167
+        raise NotImplementedError('TODO')

+ 8 - 0
hapic/ext/pyramid/context.py View File

32
         configurator: 'Configurator',
32
         configurator: 'Configurator',
33
         default_error_builder: ErrorBuilderInterface = None,
33
         default_error_builder: ErrorBuilderInterface = None,
34
     ):
34
     ):
35
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
35
         self.configurator = configurator
36
         self.configurator = configurator
36
         self.default_error_builder = \
37
         self.default_error_builder = \
37
             default_error_builder or DefaultErrorBuilder()  # FDV
38
             default_error_builder or DefaultErrorBuilder()  # FDV
181
             name=route_prefix,
182
             name=route_prefix,
182
             path=directory_path,
183
             path=directory_path,
183
         )
184
         )
185
+
186
+    def _add_exception_class_to_catch(
187
+        self,
188
+        exception_class: typing.Type[Exception],
189
+        http_code: int,
190
+    ) -> None:
191
+        raise NotImplementedError('TODO')

+ 32 - 15
tests/base.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import json
2
 import typing
3
 import typing
4
+
5
+
3
 try:  # Python 3.5+
6
 try:  # Python 3.5+
4
     from http import HTTPStatus
7
     from http import HTTPStatus
5
 except ImportError:
8
 except ImportError:
10
 from hapic.ext.bottle import BottleContext
13
 from hapic.ext.bottle import BottleContext
11
 from hapic.processor import RequestParameters
14
 from hapic.processor import RequestParameters
12
 from hapic.processor import ProcessValidationError
15
 from hapic.processor import ProcessValidationError
16
+from hapic.context import HandledException
13
 
17
 
14
 
18
 
15
 class Base(object):
19
 class Base(object):
29
         fake_files_parameters=None,
33
         fake_files_parameters=None,
30
     ) -> None:
34
     ) -> None:
31
         super().__init__(app=app)
35
         super().__init__(app=app)
36
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
37
+        self._exceptions_handler_installed = False
32
         self.fake_path_parameters = fake_path_parameters or {}
38
         self.fake_path_parameters = fake_path_parameters or {}
33
         self.fake_query_parameters = fake_query_parameters or MultiDict()
39
         self.fake_query_parameters = fake_query_parameters or MultiDict()
34
         self.fake_body_parameters = fake_body_parameters or {}
40
         self.fake_body_parameters = fake_body_parameters or {}
46
             files_parameters=self.fake_files_parameters,
52
             files_parameters=self.fake_files_parameters,
47
         )
53
         )
48
 
54
 
49
-    def get_response(
50
-        self,
51
-        response: str,
52
-        http_code: int,
53
-        mimetype: str='application/json',
54
-    ) -> typing.Any:
55
-        return {
56
-            'original_response': response,
57
-            'http_code': http_code,
58
-        }
59
-
60
     def get_validation_error_response(
55
     def get_validation_error_response(
61
         self,
56
         self,
62
         error: ProcessValidationError,
57
         error: ProcessValidationError,
63
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
58
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
64
     ) -> typing.Any:
59
     ) -> typing.Any:
65
-        return {
66
-            'original_error': error,
67
-            'http_code': http_code,
68
-        }
60
+        return self.get_response(
61
+            response=json.dumps({
62
+                'original_error': {
63
+                    'details': error.details,
64
+                    'message': error.message,
65
+                },
66
+                'http_code': http_code,
67
+            }),
68
+            http_code=http_code,
69
+        )
70
+
71
+    def _add_exception_class_to_catch(
72
+        self,
73
+        exception_class: typing.Type[Exception],
74
+        http_code: int,
75
+    ) -> None:
76
+        if not self._exceptions_handler_installed:
77
+            self._install_exceptions_handler()
78
+        self._handled_exceptions.append(
79
+            HandledException(exception_class, http_code),
80
+        )
81
+
82
+    def _get_handled_exception_class_and_http_codes(
83
+        self,
84
+    ) -> typing.List[HandledException]:
85
+        return self._handled_exceptions

+ 19 - 0
tests/ext/unit/test_bottle.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import bottle
2
 import bottle
3
+from webtest import TestApp
3
 
4
 
4
 import hapic
5
 import hapic
6
+from hapic.ext.bottle import BottleContext
5
 from tests.base import Base
7
 from tests.base import Base
6
 
8
 
7
 
9
 
74
         assert route.original_route_object.callback != MyControllers.controller_a  # nopep8
76
         assert route.original_route_object.callback != MyControllers.controller_a  # nopep8
75
         assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
77
         assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
76
         assert route.original_route_object.callback != decoration.reference.wrapper  # nopep8
78
         assert route.original_route_object.callback != decoration.reference.wrapper  # nopep8
79
+
80
+    def test_unit__general_exception_handling__ok__nominal_case(self):
81
+        hapic_ = hapic.Hapic()
82
+        app = bottle.Bottle()
83
+        context = BottleContext(app=app)
84
+        hapic_.set_context(context)
85
+
86
+        def my_view():
87
+            raise ZeroDivisionError('An exception message')
88
+
89
+        app.route('/my-view', method='GET', callback=my_view)
90
+        context.handle_exception(ZeroDivisionError, http_code=400)
91
+
92
+        test_app = TestApp(app)
93
+        response = test_app.get('/my-view', status='*')
94
+
95
+        assert 400 == response.status_code

+ 27 - 0
tests/func/test_exception_handling.py View File

1
+# coding: utf-8
2
+import bottle
3
+from webtest import TestApp
4
+
5
+from hapic import Hapic
6
+from tests.base import Base
7
+from tests.base import MyContext
8
+
9
+
10
+class TestExceptionHandling(Base):
11
+    def test_func__catch_one_exception__ok__nominal_case(self):
12
+        hapic = Hapic()
13
+        # TODO BS 2018-05-04: Make this test non-bottle
14
+        app = bottle.Bottle()
15
+        context = MyContext(app=app)
16
+        hapic.set_context(context)
17
+
18
+        def my_view():
19
+            raise ZeroDivisionError('An exception message')
20
+
21
+        app.route('/my-view', method='GET', callback=my_view)
22
+        context.handle_exception(ZeroDivisionError, http_code=400)
23
+
24
+        test_app = TestApp(app)
25
+        response = test_app.get('/my-view', status='*')
26
+
27
+        assert 400 == response.status_code

+ 21 - 7
tests/func/test_marshmallow_decoration.py View File

1
 # coding: utf-8
1
 # coding: utf-8
2
+import json
3
+
2
 try:  # Python 3.5+
4
 try:  # Python 3.5+
3
     from http import HTTPStatus
5
     from http import HTTPStatus
4
 except ImportError:
6
 except ImportError:
52
             return 'OK'
54
             return 'OK'
53
 
55
 
54
         result = my_controller()
56
         result = my_controller()
55
-        assert 'http_code' in result
56
-        assert HTTPStatus.BAD_REQUEST == result['http_code']
57
+        assert HTTPStatus.BAD_REQUEST == result.status_code
57
         assert {
58
         assert {
58
-                   'file_abc': ['Missing data for required field.']
59
-               } == result['original_error'].details
59
+                    'http_code': 400,
60
+                    'original_error': {
61
+                        'details': {
62
+                            'file_abc': ['Missing data for required field.']
63
+                        },
64
+                        'message': 'Validation error of input data'
65
+                    }
66
+               } == json.loads(result.body)
60
 
67
 
61
     def test_unit__input_files__ok__file_is_empty_string(self):
68
     def test_unit__input_files__ok__file_is_empty_string(self):
62
         hapic = Hapic()
69
         hapic = Hapic()
77
             return 'OK'
84
             return 'OK'
78
 
85
 
79
         result = my_controller()
86
         result = my_controller()
80
-        assert 'http_code' in result
81
-        assert HTTPStatus.BAD_REQUEST == result['http_code']
82
-        assert {'file_abc': ['Missing data for required field']} == result['original_error'].details
87
+        assert HTTPStatus.BAD_REQUEST == result.status_code
88
+        assert {
89
+                    'http_code': 400,
90
+                    'original_error': {
91
+                        'details': {
92
+                            'file_abc': ['Missing data for required field']
93
+                        },
94
+                        'message': 'Validation error of input data'
95
+                    }
96
+               } == json.loads(result.body)

+ 21 - 26
tests/unit/test_decorator.py View File

233
             return foo
233
             return foo
234
 
234
 
235
         result = func(42)
235
         result = func(42)
236
-        # see MyProcessor#process
237
-        assert {
238
-                   'http_code': HTTPStatus.OK,
239
-                   'original_response': '43',
240
-               } == result
236
+        assert HTTPStatus.OK == result.status_code
237
+        assert '43' == result.body
241
 
238
 
242
     def test_unit__output_data_wrapping__fail__error_response(self):
239
     def test_unit__output_data_wrapping__fail__error_response(self):
243
         context = MyContext(app=None)
240
         context = MyContext(app=None)
250
             return 'wrong result format'
247
             return 'wrong result format'
251
 
248
 
252
         result = func(42)
249
         result = func(42)
253
-        # see MyProcessor#process
254
-        assert isinstance(result, dict)
255
-        assert 'http_code' in result
256
-        assert result['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
257
-        assert 'original_error' in result
258
-        assert result['original_error'].details == {
259
-            'name': ['Missing data for required field.']
260
-        }
250
+        assert HTTPStatus.INTERNAL_SERVER_ERROR == result.status_code
251
+        assert {
252
+                   'original_error': {
253
+                       'details': {
254
+                           'name': ['Missing data for required field.']
255
+                       },
256
+                       'message': 'Validation error of output data'
257
+                   },
258
+                   'http_code': 500,
259
+               } == json.loads(result.body)
261
 
260
 
262
 
261
 
263
 class TestExceptionHandlerControllerWrapper(Base):
262
 class TestExceptionHandlerControllerWrapper(Base):
275
             raise ZeroDivisionError('We are testing')
274
             raise ZeroDivisionError('We are testing')
276
 
275
 
277
         response = func(42)
276
         response = func(42)
278
-        assert 'http_code' in response
279
-        assert response['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
280
-        assert 'original_response' in response
281
-        assert json.loads(response['original_response']) == {
282
-            'message': 'We are testing',
283
-            'details': {},
284
-            'code': None,
285
-        }
277
+        assert HTTPStatus.INTERNAL_SERVER_ERROR == response.status_code
278
+        assert {
279
+                   'details': {},
280
+                   'message': 'We are testing',
281
+                   'code': None,
282
+               } == json.loads(response.body)
286
 
283
 
287
     def test_unit__exception_handled__ok__exception_error_dict(self):
284
     def test_unit__exception_handled__ok__exception_error_dict(self):
288
         class MyException(Exception):
285
         class MyException(Exception):
305
             raise exc
302
             raise exc
306
 
303
 
307
         response = func(42)
304
         response = func(42)
308
-        assert 'http_code' in response
309
-        assert response['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
310
-        assert 'original_response' in response
311
-        assert json.loads(response['original_response']) == {
305
+        assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
306
+        assert {
312
             'message': 'We are testing',
307
             'message': 'We are testing',
313
             'details': {'foo': 'bar'},
308
             'details': {'foo': 'bar'},
314
             'code': None,
309
             'code': None,
315
-        }
310
+        } == json.loads(response.body)
316
 
311
 
317
     def test_unit__exception_handler__error__error_content_malformed(self):
312
     def test_unit__exception_handler__error__error_content_malformed(self):
318
         class MyException(Exception):
313
         class MyException(Exception):