Repository: algoo/hapic

Issue:
Add a feature to manage exceptions globally
Currently : if I need to manage an exception for all my endpoints (for example catch `Exception` in order to generate a proper `HTTP 500`), I must add `hapic.handle_exception()` decorator on every route.

Expected : I'd like to be able to define generic exception handling which would be added automatically to every route of my APIs.

The way to do so:

Add two global methods `context.handle_exception()` and `context.handle_exceptions()` which will catch exception(s) and return properly built error responses

The implementation may be something like this:

```
# Bottle context

class BottleContext(...):
    def try_catch_decorator_builder():
        # return a decorator function
        [...]

    def handle_exception(exception_class, http_error_code):
        bottle.install(try_catch_decorator_builder)
        [...]

# Pyramid context
class PyramidContext(...):
    def handle_exception(exception_class, http_error_code):
        pyramid.add_error_view(exception_class, http_error_code)
        [...]

[...]
```



Discussion:

Patch:
diff --git a/hapic/context.py b/hapic/context.py
index 5a9aa19..97aa0c4 100644
--- a/hapic/context.py
+++ b/hapic/context.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+import json
 import typing
 
 from hapic.error import ErrorBuilderInterface
@@ -106,8 +107,121 @@ class ContextInterface(object):
         """
         raise NotImplementedError()
 
+    def handle_exception(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        """
+        Enable management of this exception during execution of views. If this
+        exception caught, an http response will be returned with this http
+        code.
+        :param exception_class: Exception class to catch
+        :param http_code: HTTP code to use in response if exception caught
+        """
+        raise NotImplementedError()
+
+    def handle_exceptions(
+        self,
+        exception_classes: typing.List[typing.Type[Exception]],
+        http_code: int,
+    ) -> None:
+        """
+        Enable management of these exceptions during execution of views. If
+        this exception caught, an http response will be returned with this http
+        code.
+        :param exception_classes: Exception classes to catch
+        :param http_code: HTTP code to use in response if exception caught
+        """
+        raise NotImplementedError()
+
+
+class HandledException(object):
+    """
+    Representation of an handled exception with it's http code
+    """
+    def __init__(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int = 500,
+    ):
+        self.exception_class = exception_class
+        self.http_code = http_code
+
 
 class BaseContext(ContextInterface):
     def get_default_error_builder(self) -> ErrorBuilderInterface:
         """ see hapic.context.ContextInterface#get_default_error_builder"""
         return self.default_error_builder
+
+    def handle_exception(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        self._add_exception_class_to_catch(exception_class, http_code)
+
+    def handle_exceptions(
+        self,
+        exception_classes: typing.List[typing.Type[Exception]],
+        http_code: int,
+    ) -> None:
+        for exception_class in exception_classes:
+            self._add_exception_class_to_catch(exception_class, http_code)
+
+    def handle_exceptions_decorator_builder(
+        self,
+        func: typing.Callable[..., typing.Any],
+    ) -> typing.Callable[..., typing.Any]:
+        """
+        Return a decorator who catch exceptions raised during given function
+        execution and return a response built by the default error builder.
+
+        :param func: decorated function
+        :return: the decorator
+        """
+        def decorator(*args, **kwargs):
+            try:
+                return func(*args, **kwargs)
+            except Exception as exc:
+                # Reverse list to read first user given exception before
+                # the hapic default Exception catch
+                handled_exceptions = reversed(
+                    self._get_handled_exception_class_and_http_codes(),
+                )
+                for handled_exception in handled_exceptions:
+                    # TODO BS 2018-05-04: How to be attentive to hierarchy ?
+                    if isinstance(exc, handled_exception.exception_class):
+                        error_builder = self.get_default_error_builder()
+                        error_body = error_builder.build_from_exception(exc)
+                        return self.get_response(
+                            json.dumps(error_body),
+                            handled_exception.http_code,
+                        )
+                raise exc
+
+        return decorator
+
+    def _get_handled_exception_class_and_http_codes(
+        self,
+    ) -> typing.List[HandledException]:
+        """
+        :return: A list of tuple where: thirst item of tuple is a exception
+        class and second tuple item is a http code. This list will be used by
+        `handle_exceptions_decorator_builder` decorator to catch exceptions.
+        """
+        raise NotImplementedError()
+
+    def _add_exception_class_to_catch(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        """
+        Add an exception class to catch and matching http code. Will be used by
+        `handle_exceptions_decorator_builder` decorator to catch exceptions.
+        :param exception_class: exception class to catch
+        :param http_code: http code to use if this exception catched
+        :return:
+        """
+        raise NotImplementedError()
diff --git a/hapic/ext/bottle/context.py b/hapic/ext/bottle/context.py
index 39237ab..c5090b8 100644
--- a/hapic/ext/bottle/context.py
+++ b/hapic/ext/bottle/context.py
@@ -12,6 +12,7 @@ import bottle
 from multidict import MultiDict
 
 from hapic.context import BaseContext
+from hapic.context import HandledException
 from hapic.context import RouteRepresentation
 from hapic.decorator import DecoratedController
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
@@ -33,6 +34,8 @@ class BottleContext(BaseContext):
         app: bottle.Bottle,
         default_error_builder: ErrorBuilderInterface=None,
     ):
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
+        self._exceptions_handler_installed = False
         self.app = app
         self.default_error_builder = \
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -134,3 +137,30 @@ class BottleContext(BaseContext):
         if isinstance(response, bottle.HTTPResponse):
             return True
         return False
+
+    def _add_exception_class_to_catch(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        if not self._exceptions_handler_installed:
+            self._install_exceptions_handler()
+
+        self._handled_exceptions.append(
+            HandledException(exception_class, http_code),
+        )
+
+    def _install_exceptions_handler(self) -> None:
+        """
+        Setup the bottle app to enable exception catching with internal
+        hapic exception catcher.
+        """
+        self.app.install(self.handle_exceptions_decorator_builder)
+
+    def _get_handled_exception_class_and_http_codes(
+        self,
+    ) -> typing.List[HandledException]:
+        """
+        See hapic.context.BaseContext#_get_handled_exception_class_and_http_codes  # nopep8
+        """
+        return self._handled_exceptions
diff --git a/hapic/ext/flask/context.py b/hapic/ext/flask/context.py
index 035fdfe..0908dc2 100644
--- a/hapic/ext/flask/context.py
+++ b/hapic/ext/flask/context.py
@@ -33,6 +33,7 @@ class FlaskContext(BaseContext):
         app: Flask,
         default_error_builder: ErrorBuilderInterface=None,
     ):
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
         self.app = app
         self.default_error_builder = \
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -157,3 +158,10 @@ class FlaskContext(BaseContext):
         )
         def api_doc(path):
             return send_from_directory(directory_path, path)
+
+    def _add_exception_class_to_catch(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        raise NotImplementedError('TODO')
diff --git a/hapic/ext/pyramid/context.py b/hapic/ext/pyramid/context.py
index e0dfcb7..d39b615 100644
--- a/hapic/ext/pyramid/context.py
+++ b/hapic/ext/pyramid/context.py
@@ -32,6 +32,7 @@ class PyramidContext(BaseContext):
         configurator: 'Configurator',
         default_error_builder: ErrorBuilderInterface = None,
     ):
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
         self.configurator = configurator
         self.default_error_builder = \
             default_error_builder or DefaultErrorBuilder()  # FDV
@@ -181,3 +182,10 @@ class PyramidContext(BaseContext):
             name=route_prefix,
             path=directory_path,
         )
+
+    def _add_exception_class_to_catch(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        raise NotImplementedError('TODO')


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 7c54b09..1286176 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,5 +1,8 @@
 # -*- coding: utf-8 -*-
+import json
 import typing
+
+
 try:  # Python 3.5+
     from http import HTTPStatus
 except ImportError:
@@ -10,6 +13,7 @@ from multidict import MultiDict
 from hapic.ext.bottle import BottleContext
 from hapic.processor import RequestParameters
 from hapic.processor import ProcessValidationError
+from hapic.context import HandledException
 
 
 class Base(object):
@@ -29,6 +33,8 @@ class MyContext(BottleContext):
         fake_files_parameters=None,
     ) -> None:
         super().__init__(app=app)
+        self._handled_exceptions = []  # type: typing.List[HandledException]  # nopep8
+        self._exceptions_handler_installed = False
         self.fake_path_parameters = fake_path_parameters or {}
         self.fake_query_parameters = fake_query_parameters or MultiDict()
         self.fake_body_parameters = fake_body_parameters or {}
@@ -46,23 +52,34 @@ class MyContext(BottleContext):
             files_parameters=self.fake_files_parameters,
         )
 
-    def get_response(
-        self,
-        response: str,
-        http_code: int,
-        mimetype: str='application/json',
-    ) -> typing.Any:
-        return {
-            'original_response': response,
-            'http_code': http_code,
-        }
-
     def get_validation_error_response(
         self,
         error: ProcessValidationError,
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
     ) -> typing.Any:
-        return {
-            'original_error': error,
-            'http_code': http_code,
-        }
+        return self.get_response(
+            response=json.dumps({
+                'original_error': {
+                    'details': error.details,
+                    'message': error.message,
+                },
+                'http_code': http_code,
+            }),
+            http_code=http_code,
+        )
+
+    def _add_exception_class_to_catch(
+        self,
+        exception_class: typing.Type[Exception],
+        http_code: int,
+    ) -> None:
+        if not self._exceptions_handler_installed:
+            self._install_exceptions_handler()
+        self._handled_exceptions.append(
+            HandledException(exception_class, http_code),
+        )
+
+    def _get_handled_exception_class_and_http_codes(
+        self,
+    ) -> typing.List[HandledException]:
+        return self._handled_exceptions
diff --git a/tests/ext/unit/test_bottle.py b/tests/ext/unit/test_bottle.py
index cab6057..979e1e9 100644
--- a/tests/ext/unit/test_bottle.py
+++ b/tests/ext/unit/test_bottle.py
@@ -1,7 +1,9 @@
 # -*- coding: utf-8 -*-
 import bottle
+from webtest import TestApp
 
 import hapic
+from hapic.ext.bottle import BottleContext
 from tests.base import Base
 
 
@@ -74,3 +76,20 @@ class TestBottleExt(Base):
         assert route.original_route_object.callback != MyControllers.controller_a  # nopep8
         assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
         assert route.original_route_object.callback != decoration.reference.wrapper  # nopep8
+
+    def test_unit__general_exception_handling__ok__nominal_case(self):
+        hapic_ = hapic.Hapic()
+        app = bottle.Bottle()
+        context = BottleContext(app=app)
+        hapic_.set_context(context)
+
+        def my_view():
+            raise ZeroDivisionError('An exception message')
+
+        app.route('/my-view', method='GET', callback=my_view)
+        context.handle_exception(ZeroDivisionError, http_code=400)
+
+        test_app = TestApp(app)
+        response = test_app.get('/my-view', status='*')
+
+        assert 400 == response.status_code
diff --git a/tests/func/test_exception_handling.py b/tests/func/test_exception_handling.py
new file mode 100644
index 0000000..4458660
--- /dev/null
+++ b/tests/func/test_exception_handling.py
@@ -0,0 +1,27 @@
+# coding: utf-8
+import bottle
+from webtest import TestApp
+
+from hapic import Hapic
+from tests.base import Base
+from tests.base import MyContext
+
+
+class TestExceptionHandling(Base):
+    def test_func__catch_one_exception__ok__nominal_case(self):
+        hapic = Hapic()
+        # TODO BS 2018-05-04: Make this test non-bottle
+        app = bottle.Bottle()
+        context = MyContext(app=app)
+        hapic.set_context(context)
+
+        def my_view():
+            raise ZeroDivisionError('An exception message')
+
+        app.route('/my-view', method='GET', callback=my_view)
+        context.handle_exception(ZeroDivisionError, http_code=400)
+
+        test_app = TestApp(app)
+        response = test_app.get('/my-view', status='*')
+
+        assert 400 == response.status_code
diff --git a/tests/func/test_marshmallow_decoration.py b/tests/func/test_marshmallow_decoration.py
index 9223a14..331fe9a 100644
--- a/tests/func/test_marshmallow_decoration.py
+++ b/tests/func/test_marshmallow_decoration.py
@@ -1,4 +1,6 @@
 # coding: utf-8
+import json
+
 try:  # Python 3.5+
     from http import HTTPStatus
 except ImportError:
@@ -52,11 +54,16 @@ class TestMarshmallowDecoration(Base):
             return 'OK'
 
         result = my_controller()
-        assert 'http_code' in result
-        assert HTTPStatus.BAD_REQUEST == result['http_code']
+        assert HTTPStatus.BAD_REQUEST == result.status_code
         assert {
-                   'file_abc': ['Missing data for required field.']
-               } == result['original_error'].details
+                    'http_code': 400,
+                    'original_error': {
+                        'details': {
+                            'file_abc': ['Missing data for required field.']
+                        },
+                        'message': 'Validation error of input data'
+                    }
+               } == json.loads(result.body)
 
     def test_unit__input_files__ok__file_is_empty_string(self):
         hapic = Hapic()
@@ -77,6 +84,13 @@ class TestMarshmallowDecoration(Base):
             return 'OK'
 
         result = my_controller()
-        assert 'http_code' in result
-        assert HTTPStatus.BAD_REQUEST == result['http_code']
-        assert {'file_abc': ['Missing data for required field']} == result['original_error'].details
+        assert HTTPStatus.BAD_REQUEST == result.status_code
+        assert {
+                    'http_code': 400,
+                    'original_error': {
+                        'details': {
+                            'file_abc': ['Missing data for required field']
+                        },
+                        'message': 'Validation error of input data'
+                    }
+               } == json.loads(result.body)
diff --git a/tests/unit/test_decorator.py b/tests/unit/test_decorator.py
index 43f6a7b..e088a8a 100644
--- a/tests/unit/test_decorator.py
+++ b/tests/unit/test_decorator.py
@@ -233,11 +233,8 @@ class TestOutputControllerWrapper(Base):
             return foo
 
         result = func(42)
-        # see MyProcessor#process
-        assert {
-                   'http_code': HTTPStatus.OK,
-                   'original_response': '43',
-               } == result
+        assert HTTPStatus.OK == result.status_code
+        assert '43' == result.body
 
     def test_unit__output_data_wrapping__fail__error_response(self):
         context = MyContext(app=None)
@@ -250,14 +247,16 @@ class TestOutputControllerWrapper(Base):
             return 'wrong result format'
 
         result = func(42)
-        # see MyProcessor#process
-        assert isinstance(result, dict)
-        assert 'http_code' in result
-        assert result['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
-        assert 'original_error' in result
-        assert result['original_error'].details == {
-            'name': ['Missing data for required field.']
-        }
+        assert HTTPStatus.INTERNAL_SERVER_ERROR == result.status_code
+        assert {
+                   'original_error': {
+                       'details': {
+                           'name': ['Missing data for required field.']
+                       },
+                       'message': 'Validation error of output data'
+                   },
+                   'http_code': 500,
+               } == json.loads(result.body)
 
 
 class TestExceptionHandlerControllerWrapper(Base):
@@ -275,14 +274,12 @@ class TestExceptionHandlerControllerWrapper(Base):
             raise ZeroDivisionError('We are testing')
 
         response = func(42)
-        assert 'http_code' in response
-        assert response['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
-        assert 'original_response' in response
-        assert json.loads(response['original_response']) == {
-            'message': 'We are testing',
-            'details': {},
-            'code': None,
-        }
+        assert HTTPStatus.INTERNAL_SERVER_ERROR == response.status_code
+        assert {
+                   'details': {},
+                   'message': 'We are testing',
+                   'code': None,
+               } == json.loads(response.body)
 
     def test_unit__exception_handled__ok__exception_error_dict(self):
         class MyException(Exception):
@@ -305,14 +302,12 @@ class TestExceptionHandlerControllerWrapper(Base):
             raise exc
 
         response = func(42)
-        assert 'http_code' in response
-        assert response['http_code'] == HTTPStatus.INTERNAL_SERVER_ERROR
-        assert 'original_response' in response
-        assert json.loads(response['original_response']) == {
+        assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
+        assert {
             'message': 'We are testing',
             'details': {'foo': 'bar'},
             'code': None,
-        }
+        } == json.loads(response.body)
 
     def test_unit__exception_handler__error__error_content_malformed(self):
         class MyException(Exception):