Browse Source

Start introduce to hapic global exception handling

Bastien Sevajol 6 years ago
parent
commit
92f11b2eef

+ 73 - 0
hapic/context.py View File

@@ -1,4 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
2 3
 import typing
3 4
 
4 5
 from hapic.error import ErrorBuilderInterface
@@ -106,8 +107,80 @@ class ContextInterface(object):
106 107
         """
107 108
         raise NotImplementedError()
108 109
 
110
+    def handle_exception(
111
+        self,
112
+        exception_class: typing.Type[Exception],
113
+        http_code: int,
114
+    ) -> None:
115
+        raise NotImplementedError()
116
+
117
+    def handle_exceptions(
118
+        self,
119
+        exception_classes: typing.List[typing.Type[Exception]],
120
+        http_code: int,
121
+    ) -> None:
122
+        raise NotImplementedError()
123
+
124
+    def _add_exception_class_to_catch(
125
+        self,
126
+        exception_class: typing.List[typing.Type[Exception]],
127
+        http_code: int,
128
+    ) -> None:
129
+        raise NotImplementedError()
130
+
109 131
 
110 132
 class BaseContext(ContextInterface):
111 133
     def get_default_error_builder(self) -> ErrorBuilderInterface:
112 134
         """ see hapic.context.ContextInterface#get_default_error_builder"""
113 135
         return self.default_error_builder
136
+
137
+    def handle_exception(
138
+        self,
139
+        exception_class: typing.Type[Exception],
140
+        http_code: int,
141
+    ) -> None:
142
+        self._add_exception_class_to_catch(exception_class, http_code)
143
+
144
+    def handle_exceptions(
145
+        self,
146
+        exception_classes: typing.List[typing.Type[Exception]],
147
+        http_code: int,
148
+    ) -> None:
149
+        for exception_class in exception_classes:
150
+            self._add_exception_class_to_catch(exception_class, http_code)
151
+
152
+    def handle_exceptions_decorator_builder(
153
+        self,
154
+        func: typing.Callable[..., typing.Any],
155
+    ) -> typing.Callable[..., typing.Any]:
156
+        def decorator(*args, **kwargs):
157
+            try:
158
+                return func(*args, **kwargs)
159
+            except Exception as exc:
160
+                # Reverse list to read first user given exception before
161
+                # the hapic default Exception catch
162
+                handled = reversed(self._get_handled_exception_classes())
163
+                for handled_exception_class, http_code in handled:
164
+                    # TODO BS 2018-05-04: How to be attentive to hierarchy ?
165
+                    if isinstance(exc, handled_exception_class):
166
+                        error_builder = self.get_default_error_builder()
167
+                        error_body = error_builder.build_from_exception(exc)
168
+                        return self.get_response(
169
+                            json.dumps(error_body),
170
+                            http_code,
171
+                        )
172
+                raise exc
173
+
174
+        return decorator
175
+
176
+    def _get_handled_exception_classes(
177
+        self,
178
+    ) -> typing.List[typing.Tuple[typing.Type[Exception], int]]:
179
+        raise NotImplementedError()
180
+
181
+    def _add_exception_class_to_catch(
182
+        self,
183
+        exception_class: typing.Type[Exception],
184
+        http_code: int,
185
+    ) -> None:
186
+        raise NotImplementedError()

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

@@ -33,6 +33,7 @@ class BottleContext(BaseContext):
33 33
         app: bottle.Bottle,
34 34
         default_error_builder: ErrorBuilderInterface=None,
35 35
     ):
36
+        self._handled_exceptions = []  # type: typing.List[typing.Tuple[typing.Type[Exception], int]]  # nopep8
36 37
         self.app = app
37 38
         self.default_error_builder = \
38 39
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -134,3 +135,13 @@ class BottleContext(BaseContext):
134 135
         if isinstance(response, bottle.HTTPResponse):
135 136
             return True
136 137
         return False
138
+
139
+    def _add_exception_class_to_catch(
140
+        self,
141
+        exception_class: typing.Type[Exception],
142
+        http_code: int,
143
+    ) -> None:
144
+        self._handled_exceptions.append((exception_class, http_code))
145
+
146
+    def _install_exceptions_handler(self) -> None:
147
+        self.app.install(self.handle_exceptions_decorator_builder)

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

@@ -33,6 +33,7 @@ class FlaskContext(BaseContext):
33 33
         app: Flask,
34 34
         default_error_builder: ErrorBuilderInterface=None,
35 35
     ):
36
+        self._handled_exceptions = []  # type: typing.List[typing.Tuple[typing.Type[Exception], int]]  # nopep8
36 37
         self.app = app
37 38
         self.default_error_builder = \
38 39
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -157,3 +158,11 @@ class FlaskContext(BaseContext):
157 158
         )
158 159
         def api_doc(path):
159 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
+        self._handled_exceptions.append((exception_class, http_code))
168
+

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

@@ -32,6 +32,7 @@ class PyramidContext(BaseContext):
32 32
         configurator: 'Configurator',
33 33
         default_error_builder: ErrorBuilderInterface = None,
34 34
     ):
35
+        self._handled_exceptions = []  # type: typing.List[typing.Tuple[typing.Type[Exception], int]]  # nopep8
35 36
         self.configurator = configurator
36 37
         self.default_error_builder = \
37 38
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -181,3 +182,10 @@ class PyramidContext(BaseContext):
181 182
             name=route_prefix,
182 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
+        self._handled_exceptions.append((exception_class, http_code))

+ 28 - 15
tests/base.py View File

@@ -1,4 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
2 3
 import typing
3 4
 try:  # Python 3.5+
4 5
     from http import HTTPStatus
@@ -6,6 +7,7 @@ except ImportError:
6 7
     from http import client as HTTPStatus
7 8
 
8 9
 from multidict import MultiDict
10
+import bottle
9 11
 
10 12
 from hapic.ext.bottle import BottleContext
11 13
 from hapic.processor import RequestParameters
@@ -29,6 +31,8 @@ class MyContext(BottleContext):
29 31
         fake_files_parameters=None,
30 32
     ) -> None:
31 33
         super().__init__(app=app)
34
+        self._handled_exceptions = []  # type: typing.List[typing.Tuple[typing.Type[Exception], int]]  # nopep8
35
+        self._exceptions_handler_installed = False
32 36
         self.fake_path_parameters = fake_path_parameters or {}
33 37
         self.fake_query_parameters = fake_query_parameters or MultiDict()
34 38
         self.fake_body_parameters = fake_body_parameters or {}
@@ -46,23 +50,32 @@ class MyContext(BottleContext):
46 50
             files_parameters=self.fake_files_parameters,
47 51
         )
48 52
 
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 53
     def get_validation_error_response(
61 54
         self,
62 55
         error: ProcessValidationError,
63 56
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
64 57
     ) -> typing.Any:
65
-        return {
66
-            'original_error': error,
67
-            'http_code': http_code,
68
-        }
58
+        return self.get_response(
59
+            response=json.dumps({
60
+                'original_error': {
61
+                    'details': error.details,
62
+                    'message': error.message,
63
+                },
64
+                'http_code': http_code,
65
+            }),
66
+            http_code=http_code,
67
+        )
68
+
69
+    def _add_exception_class_to_catch(
70
+        self,
71
+        exception_class: typing.Type[Exception],
72
+        http_code: int,
73
+    ) -> None:
74
+        if not self._exceptions_handler_installed:
75
+            self._install_exceptions_handler()
76
+        self._handled_exceptions.append((exception_class, http_code))
77
+
78
+    def _get_handled_exception_classes(
79
+        self,
80
+    ) -> typing.List[typing.Tuple[typing.Type[Exception], int]]:
81
+        return self._handled_exceptions

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

@@ -0,0 +1,27 @@
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,4 +1,6 @@
1 1
 # coding: utf-8
2
+import json
3
+
2 4
 try:  # Python 3.5+
3 5
     from http import HTTPStatus
4 6
 except ImportError:
@@ -52,11 +54,16 @@ class TestMarshmallowDecoration(Base):
52 54
             return 'OK'
53 55
 
54 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 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 68
     def test_unit__input_files__ok__file_is_empty_string(self):
62 69
         hapic = Hapic()
@@ -77,6 +84,13 @@ class TestMarshmallowDecoration(Base):
77 84
             return 'OK'
78 85
 
79 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)

+ 6 - 10
tests/unit/test_decorator.py View File

@@ -233,11 +233,8 @@ class TestOutputControllerWrapper(Base):
233 233
             return foo
234 234
 
235 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 239
     def test_unit__output_data_wrapping__fail__error_response(self):
243 240
         context = MyContext(app=None)
@@ -250,6 +247,7 @@ class TestOutputControllerWrapper(Base):
250 247
             return 'wrong result format'
251 248
 
252 249
         result = func(42)
250
+        assert HTTPStatus.INTERNAL_SERVER_ERROR == result.status_code
253 251
         # see MyProcessor#process
254 252
         assert isinstance(result, dict)
255 253
         assert 'http_code' in result
@@ -305,14 +303,12 @@ class TestExceptionHandlerControllerWrapper(Base):
305 303
             raise exc
306 304
 
307 305
         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']) == {
306
+        assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
307
+        assert {
312 308
             'message': 'We are testing',
313 309
             'details': {'foo': 'bar'},
314 310
             'code': None,
315
-        }
311
+        } == json.loads(response.body)
316 312
 
317 313
     def test_unit__exception_handler__error__error_content_malformed(self):
318 314
         class MyException(Exception):