Browse Source

Merge pull request #45 from algoo/develop

Bastien Sevajol 6 years ago
parent
commit
5c71c0ea5f
No account linked to committer's email

+ 114 - 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,121 @@ 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
+        """
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 152
 class BaseContext(ContextInterface):
111 153
     def get_default_error_builder(self) -> ErrorBuilderInterface:
112 154
         """ see hapic.context.ContextInterface#get_default_error_builder"""
113 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,6 +12,7 @@ import bottle
12 12
 from multidict import MultiDict
13 13
 
14 14
 from hapic.context import BaseContext
15
+from hapic.context import HandledException
15 16
 from hapic.context import RouteRepresentation
16 17
 from hapic.decorator import DecoratedController
17 18
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
@@ -33,6 +34,8 @@ class BottleContext(BaseContext):
33 34
         app: bottle.Bottle,
34 35
         default_error_builder: ErrorBuilderInterface=None,
35 36
     ):
37
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
38
+        self._exceptions_handler_installed = False
36 39
         self.app = app
37 40
         self.default_error_builder = \
38 41
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -134,3 +137,30 @@ class BottleContext(BaseContext):
134 137
         if isinstance(response, bottle.HTTPResponse):
135 138
             return True
136 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,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[HandledException]  # nopep8
36 37
         self.app = app
37 38
         self.default_error_builder = \
38 39
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -157,3 +158,10 @@ 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
+        raise NotImplementedError('TODO')

+ 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[HandledException]  # 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
+        raise NotImplementedError('TODO')

+ 32 - 15
tests/base.py View File

@@ -1,5 +1,8 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
2 3
 import typing
4
+
5
+
3 6
 try:  # Python 3.5+
4 7
     from http import HTTPStatus
5 8
 except ImportError:
@@ -10,6 +13,7 @@ from multidict import MultiDict
10 13
 from hapic.ext.bottle import BottleContext
11 14
 from hapic.processor import RequestParameters
12 15
 from hapic.processor import ProcessValidationError
16
+from hapic.context import HandledException
13 17
 
14 18
 
15 19
 class Base(object):
@@ -29,6 +33,8 @@ class MyContext(BottleContext):
29 33
         fake_files_parameters=None,
30 34
     ) -> None:
31 35
         super().__init__(app=app)
36
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
37
+        self._exceptions_handler_installed = False
32 38
         self.fake_path_parameters = fake_path_parameters or {}
33 39
         self.fake_query_parameters = fake_query_parameters or MultiDict()
34 40
         self.fake_body_parameters = fake_body_parameters or {}
@@ -46,23 +52,34 @@ class MyContext(BottleContext):
46 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 55
     def get_validation_error_response(
61 56
         self,
62 57
         error: ProcessValidationError,
63 58
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
64 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,7 +1,9 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import bottle
3
+from webtest import TestApp
3 4
 
4 5
 import hapic
6
+from hapic.ext.bottle import BottleContext
5 7
 from tests.base import Base
6 8
 
7 9
 
@@ -74,3 +76,20 @@ class TestBottleExt(Base):
74 76
         assert route.original_route_object.callback != MyControllers.controller_a  # nopep8
75 77
         assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
76 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

@@ -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)

+ 21 - 26
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,14 +247,16 @@ class TestOutputControllerWrapper(Base):
250 247
             return 'wrong result format'
251 248
 
252 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 262
 class TestExceptionHandlerControllerWrapper(Base):
@@ -275,14 +274,12 @@ class TestExceptionHandlerControllerWrapper(Base):
275 274
             raise ZeroDivisionError('We are testing')
276 275
 
277 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 284
     def test_unit__exception_handled__ok__exception_error_dict(self):
288 285
         class MyException(Exception):
@@ -305,14 +302,12 @@ class TestExceptionHandlerControllerWrapper(Base):
305 302
             raise exc
306 303
 
307 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 307
             'message': 'We are testing',
313 308
             'details': {'foo': 'bar'},
314 309
             'code': None,
315
-        }
310
+        } == json.loads(response.body)
316 311
 
317 312
     def test_unit__exception_handler__error__error_content_malformed(self):
318 313
         class MyException(Exception):