Browse Source

validation error use now the error builder

Bastien Sevajol 6 years ago
parent
commit
ad5671a3d0

+ 16 - 0
hapic/context.py View File

@@ -1,5 +1,8 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import typing
3
+
4
+from hapic.error import ErrorBuilderInterface
5
+
3 6
 try:  # Python 3.5+
4 7
     from http import HTTPStatus
5 8
 except ImportError:
@@ -68,3 +71,16 @@ class ContextInterface(object):
68 71
         :return:
69 72
         """
70 73
         raise NotImplementedError()
74
+
75
+    def get_default_error_builder(self) -> ErrorBuilderInterface:
76
+        """
77
+        Return a ErrorBuilder who will be used to build default errors
78
+        :return: ErrorBuilderInterface instance
79
+        """
80
+        raise NotImplementedError()
81
+
82
+
83
+class BaseContext(ContextInterface):
84
+    def get_default_error_builder(self) -> ErrorBuilderInterface:
85
+        """ see hapic.context.ContextInterface#get_default_error_builder"""
86
+        return self.default_error_builder

+ 23 - 14
hapic/decorator.py View File

@@ -6,16 +6,15 @@ try:  # Python 3.5+
6 6
 except ImportError:
7 7
     from http import client as HTTPStatus
8 8
 
9
-# TODO BS 20171010: bottle specific !  # see #5
10
-import marshmallow
11 9
 from multidict import MultiDict
12
-
13 10
 from hapic.data import HapicData
14 11
 from hapic.description import ControllerDescription
15 12
 from hapic.exception import ProcessException
13
+from hapic.exception import OutputValidationException
16 14
 from hapic.context import ContextInterface
17 15
 from hapic.processor import ProcessorInterface
18 16
 from hapic.processor import RequestParameters
17
+from hapic.error import ErrorBuilderInterface
19 18
 
20 19
 # TODO: Ensure usage of DECORATION_ATTRIBUTE_NAME is documented and
21 20
 # var names correctly choose.  see #6
@@ -386,13 +385,13 @@ class ExceptionHandlerControllerWrapper(ControllerWrapper):
386 385
         self,
387 386
         handled_exception_class: typing.Type[Exception],
388 387
         context: typing.Union[ContextInterface, typing.Callable[[], ContextInterface]],  # nopep8
389
-        schema: marshmallow.Schema,
388
+        error_builder: typing.Union[ErrorBuilderInterface, typing.Callable[[], ErrorBuilderInterface]],  # nopep8
390 389
         http_code: HTTPStatus=HTTPStatus.INTERNAL_SERVER_ERROR,
391 390
     ) -> None:
392 391
         self.handled_exception_class = handled_exception_class
393 392
         self._context = context
394 393
         self.http_code = http_code
395
-        self.schema = schema
394
+        self._error_builder = error_builder
396 395
 
397 396
     @property
398 397
     def context(self) -> ContextInterface:
@@ -400,6 +399,12 @@ class ExceptionHandlerControllerWrapper(ControllerWrapper):
400 399
             return self._context()
401 400
         return self._context
402 401
 
402
+    @property
403
+    def error_builder(self) -> ErrorBuilderInterface:
404
+        if callable(self._error_builder):
405
+            return self._error_builder()
406
+        return self._error_builder
407
+
403 408
     def _execute_wrapped_function(
404 409
         self,
405 410
         func,
@@ -413,17 +418,21 @@ class ExceptionHandlerControllerWrapper(ControllerWrapper):
413 418
                 func_kwargs,
414 419
             )
415 420
         except self.handled_exception_class as exc:
416
-            # TODO: "error_detail" attribute name should be configurable
417
-            # TODO BS 20171013: use overrideable mechanism, error object given
418
-            #  to schema ? see #15
419
-            raw_response = {
420
-                'message': str(exc),
421
-                'code': None,
422
-                'detail': getattr(exc, 'error_detail', {}),
423
-            }
421
+            response_content = self.error_builder.build_from_exception(exc)
422
+
423
+            # Check error format
424
+            dumped = self.error_builder.dump(response_content).data
425
+            unmarshall = self.error_builder.load(dumped)
426
+            if unmarshall.errors:
427
+                raise OutputValidationException(
428
+                    'Validation error during dump of error response: {}'
429
+                    .format(
430
+                        str(unmarshall.errors)
431
+                    )
432
+                )
424 433
 
425 434
             error_response = self.context.get_response(
426
-                raw_response,
435
+                response_content,
427 436
                 self.http_code,
428 437
             )
429 438
             return error_response

+ 60 - 0
hapic/error.py View File

@@ -0,0 +1,60 @@
1
+# -*- coding: utf-8 -*-
2
+import marshmallow
3
+
4
+from hapic.processor import ProcessValidationError
5
+
6
+
7
+class ErrorBuilderInterface(marshmallow.Schema):
8
+    """
9
+    ErrorBuilder is a class who represent a Schema (marshmallow.Schema) and
10
+    can generate a response content from exception (build_from_exception)
11
+    """
12
+    def build_from_exception(self, exception: Exception) -> dict:
13
+        """
14
+        Build the error response content from given exception
15
+        :param exception: Original exception who invoke this method
16
+        :return: a dict representing the error response content
17
+        """
18
+        raise NotImplementedError()
19
+
20
+    def build_from_validation_error(
21
+        self,
22
+        error: ProcessValidationError,
23
+    ) -> dict:
24
+        """
25
+        Build the error response content from given process validation error
26
+        :param error: Original ProcessValidationError who invoke this method
27
+        :return: a dict representing the error response content
28
+        """
29
+        raise NotImplementedError()
30
+
31
+
32
+class DefaultErrorBuilder(ErrorBuilderInterface):
33
+    message = marshmallow.fields.String(required=True)
34
+    details = marshmallow.fields.Dict(required=False, missing={})
35
+    code = marshmallow.fields.Raw(missing=None)
36
+
37
+    def build_from_exception(self, exception: Exception) -> dict:
38
+        """
39
+        See hapic.error.ErrorBuilderInterface#build_from_exception docstring
40
+        """
41
+        # TODO: "error_detail" attribute name should be configurable
42
+        return {
43
+            'message': str(exception),
44
+            'details': getattr(exception, 'error_detail', {}),
45
+            'code': None,
46
+        }
47
+
48
+    def build_from_validation_error(
49
+        self,
50
+        error: ProcessValidationError,
51
+    ) -> dict:
52
+        """
53
+        See hapic.error.ErrorBuilderInterface#build_from_validation_error
54
+        docstring
55
+        """
56
+        return {
57
+            'message': error.message,
58
+            'details': error.details,
59
+            'code': None,
60
+        }

+ 20 - 7
hapic/ext/bottle/context.py View File

@@ -2,6 +2,7 @@
2 2
 import json
3 3
 import re
4 4
 import typing
5
+
5 6
 try:  # Python 3.5+
6 7
     from http import HTTPStatus
7 8
 except ImportError:
@@ -10,7 +11,7 @@ except ImportError:
10 11
 import bottle
11 12
 from multidict import MultiDict
12 13
 
13
-from hapic.context import ContextInterface
14
+from hapic.context import BaseContext
14 15
 from hapic.context import RouteRepresentation
15 16
 from hapic.decorator import DecoratedController
16 17
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
@@ -19,14 +20,22 @@ from hapic.exception import NoRoutesException
19 20
 from hapic.exception import RouteNotFound
20 21
 from hapic.processor import RequestParameters
21 22
 from hapic.processor import ProcessValidationError
23
+from hapic.error import DefaultErrorBuilder
24
+from hapic.error import ErrorBuilderInterface
22 25
 
23 26
 # Bottle regular expression to locate url parameters
24 27
 BOTTLE_RE_PATH_URL = re.compile(r'<([^:<>]+)(?::[^<>]+)?>')
25 28
 
26 29
 
27
-class BottleContext(ContextInterface):
28
-    def __init__(self, app: bottle.Bottle):
30
+class BottleContext(BaseContext):
31
+    def __init__(
32
+        self,
33
+        app: bottle.Bottle,
34
+        default_error_builder: ErrorBuilderInterface=None,
35
+    ):
29 36
         self.app = app
37
+        self.default_error_builder = \
38
+            default_error_builder or DefaultErrorBuilder()  # FDV
30 39
 
31 40
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
32 41
         path_parameters = dict(bottle.request.url_args)
@@ -63,9 +72,13 @@ class BottleContext(ContextInterface):
63 72
         error: ProcessValidationError,
64 73
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
65 74
     ) -> typing.Any:
66
-        # TODO BS 20171010: Manage error schemas, see #4
67
-        from hapic.hapic import _default_global_error_schema
68
-        unmarshall = _default_global_error_schema.dump(error)
75
+        error_content = self.default_error_builder.build_from_validation_error(
76
+            error,
77
+        )
78
+
79
+        # Check error
80
+        dumped = self.default_error_builder.dump(error).data
81
+        unmarshall = self.default_error_builder.load(dumped)
69 82
         if unmarshall.errors:
70 83
             raise OutputValidationException(
71 84
                 'Validation error during dump of error response: {}'.format(
@@ -74,7 +87,7 @@ class BottleContext(ContextInterface):
74 87
             )
75 88
 
76 89
         return bottle.HTTPResponse(
77
-            body=json.dumps(unmarshall.data),
90
+            body=json.dumps(error_content),
78 91
             headers=[
79 92
                 ('Content-Type', 'application/json'),
80 93
             ],

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

@@ -2,17 +2,21 @@
2 2
 import json
3 3
 import re
4 4
 import typing
5
+
5 6
 try:  # Python 3.5+
6 7
     from http import HTTPStatus
7 8
 except ImportError:
8 9
     from http import client as HTTPStatus
9 10
 
10
-from hapic.context import ContextInterface
11
+from hapic.context import BaseContext
11 12
 from hapic.context import RouteRepresentation
12 13
 from hapic.decorator import DecoratedController
13 14
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
14 15
 from hapic.exception import OutputValidationException
15
-from hapic.processor import RequestParameters, ProcessValidationError
16
+from hapic.processor import RequestParameters
17
+from hapic.processor import ProcessValidationError
18
+from hapic.error import DefaultErrorBuilder
19
+from hapic.error import ErrorBuilderInterface
16 20
 from flask import Flask
17 21
 
18 22
 if typing.TYPE_CHECKING:
@@ -22,9 +26,15 @@ if typing.TYPE_CHECKING:
22 26
 FLASK_RE_PATH_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
23 27
 
24 28
 
25
-class FlaskContext(ContextInterface):
26
-    def __init__(self, app: Flask):
29
+class FlaskContext(BaseContext):
30
+    def __init__(
31
+        self,
32
+        app: Flask,
33
+        default_error_builder: ErrorBuilderInterface=None,
34
+    ):
27 35
         self.app = app
36
+        self.default_error_builder = \
37
+            default_error_builder or DefaultErrorBuilder()  # FDV
28 38
 
29 39
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
30 40
         from flask import request
@@ -34,7 +44,7 @@ class FlaskContext(ContextInterface):
34 44
             body_parameters=request.get_json(),  # TODO: Check
35 45
             form_parameters=request.form,
36 46
             header_parameters=request.headers,
37
-            files_parameters={},  # TODO: BS 20171115: Code it
47
+            files_parameters=request.files,
38 48
         )
39 49
 
40 50
     def get_response(
@@ -54,9 +64,14 @@ class FlaskContext(ContextInterface):
54 64
         error: ProcessValidationError,
55 65
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
56 66
     ) -> typing.Any:
57
-        # TODO BS 20171010: Manage error schemas, see #4
58
-        from hapic.hapic import _default_global_error_schema
59
-        unmarshall = _default_global_error_schema.dump(error)
67
+        error_content = self.default_error_builder.build_from_validation_error(
68
+            error,
69
+        )
70
+
71
+        # Check error
72
+        dumped = self.default_error_builder.dump(error).data
73
+        unmarshall = self.default_error_builder.load(dumped)
74
+
60 75
         if unmarshall.errors:
61 76
             raise OutputValidationException(
62 77
                 'Validation error during dump of error response: {}'.format(
@@ -65,7 +80,7 @@ class FlaskContext(ContextInterface):
65 80
             )
66 81
         from flask import Response
67 82
         return Response(
68
-            response=json.dumps(unmarshall.data),
83
+            response=json.dumps(error_content),
69 84
             mimetype='application/json',
70 85
             status=int(http_code),
71 86
         )

+ 21 - 7
hapic/ext/pyramid/context.py View File

@@ -2,18 +2,21 @@
2 2
 import json
3 3
 import re
4 4
 import typing
5
+
5 6
 try:  # Python 3.5+
6 7
     from http import HTTPStatus
7 8
 except ImportError:
8 9
     from http import client as HTTPStatus
9 10
 
10
-from hapic.context import ContextInterface
11
+from hapic.context import BaseContext
11 12
 from hapic.context import RouteRepresentation
12 13
 from hapic.decorator import DecoratedController
13 14
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
14 15
 from hapic.exception import OutputValidationException
15 16
 from hapic.processor import RequestParameters
16 17
 from hapic.processor import ProcessValidationError
18
+from hapic.error import DefaultErrorBuilder
19
+from hapic.error import ErrorBuilderInterface
17 20
 
18 21
 if typing.TYPE_CHECKING:
19 22
     from pyramid.response import Response
@@ -23,9 +26,15 @@ if typing.TYPE_CHECKING:
23 26
 PYRAMID_RE_PATH_URL = re.compile(r'')
24 27
 
25 28
 
26
-class PyramidContext(ContextInterface):
27
-    def __init__(self, configurator: 'Configurator'):
29
+class PyramidContext(BaseContext):
30
+    def __init__(
31
+        self,
32
+        configurator: 'Configurator',
33
+        default_error_builder: ErrorBuilderInterface = None,
34
+    ):
28 35
         self.configurator = configurator
36
+        self.default_error_builder = \
37
+            default_error_builder or DefaultErrorBuilder()  # FDV
29 38
 
30 39
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
31 40
         req = args[-1]  # TODO : Check
@@ -65,10 +74,15 @@ class PyramidContext(ContextInterface):
65 74
         error: ProcessValidationError,
66 75
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
67 76
     ) -> typing.Any:
68
-        # TODO BS 20171010: Manage error schemas, see #4
69 77
         from pyramid.response import Response
70
-        from hapic.hapic import _default_global_error_schema
71
-        unmarshall = _default_global_error_schema.dump(error)
78
+
79
+        error_content = self.default_error_builder.build_from_validation_error(
80
+            error,
81
+        )
82
+
83
+        # Check error
84
+        dumped = self.default_error_builder.dump(error).data
85
+        unmarshall = self.default_error_builder.load(dumped)
72 86
         if unmarshall.errors:
73 87
             raise OutputValidationException(
74 88
                 'Validation error during dump of error response: {}'.format(
@@ -77,7 +91,7 @@ class PyramidContext(ContextInterface):
77 91
             )
78 92
 
79 93
         return Response(
80
-            body=json.dumps(unmarshall.data),
94
+            body=json.dumps(error_content),
81 95
             headers=[
82 96
                 ('Content-Type', 'application/json'),
83 97
             ],

+ 14 - 16
hapic/hapic.py View File

@@ -1,15 +1,12 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import typing
3 3
 import uuid
4
+import functools
4 5
 try:  # Python 3.5+
5 6
     from http import HTTPStatus
6 7
 except ImportError:
7 8
     from http import client as HTTPStatus
8 9
 
9
-import functools
10
-
11
-import marshmallow
12
-
13 10
 from hapic.buffer import DecorationBuffer
14 11
 from hapic.context import ContextInterface
15 12
 from hapic.decorator import DecoratedController
@@ -39,15 +36,7 @@ from hapic.processor import ProcessorInterface
39 36
 from hapic.processor import MarshmallowInputProcessor
40 37
 from hapic.processor import MarshmallowInputFilesProcessor
41 38
 from hapic.processor import MarshmallowOutputProcessor
42
-
43
-
44
-class ErrorResponseSchema(marshmallow.Schema):
45
-    message = marshmallow.fields.String(required=True)
46
-    details = marshmallow.fields.Dict(required=False, missing={})
47
-    code = marshmallow.fields.Raw(missing=None)
48
-
49
-
50
-_default_global_error_schema = ErrorResponseSchema()
39
+from hapic.error import ErrorBuilderInterface
51 40
 
52 41
 
53 42
 # TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb), see #12
@@ -59,6 +48,7 @@ class Hapic(object):
59 48
         self._buffer = DecorationBuffer()
60 49
         self._controllers = []  # type: typing.List[DecoratedController]
61 50
         self._context = None  # type: ContextInterface
51
+        self._error_builder = None  # type: ErrorBuilderInterface
62 52
         self.doc_generator = DocGenerator()
63 53
 
64 54
         # This local function will be pass to different components
@@ -67,7 +57,14 @@ class Hapic(object):
67 57
         def context_getter():
68 58
             return self._context
69 59
 
60
+        # This local function will be pass to different components
61
+        # who will need error_builder but declared (like with decorator)
62
+        # before error_builder declaration
63
+        def error_builder_getter():
64
+            return self._context.get_default_error_builder()
65
+
70 66
         self._context_getter = context_getter
67
+        self._error_builder_getter = error_builder_getter
71 68
 
72 69
         # TODO: Permettre la surcharge des classes utilisés ci-dessous, see #14
73 70
 
@@ -346,17 +343,18 @@ class Hapic(object):
346 343
 
347 344
     def handle_exception(
348 345
         self,
349
-        handled_exception_class: typing.Type[Exception],
346
+        handled_exception_class: typing.Type[Exception]=Exception,
350 347
         http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
348
+        error_builder: ErrorBuilderInterface=None,
351 349
         context: ContextInterface = None,
352 350
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
353 351
         context = context or self._context_getter
352
+        error_builder = error_builder or self._error_builder_getter
354 353
 
355 354
         decoration = ExceptionHandlerControllerWrapper(
356 355
             handled_exception_class,
357 356
             context,
358
-            # TODO BS 20171013: Permit schema overriding, see #15
359
-            schema=_default_global_error_schema,
357
+            error_builder=error_builder,
360 358
             http_code=http_code,
361 359
         )
362 360
 

+ 1 - 0
tests/func/fake_api/test_bottle.py View File

@@ -59,6 +59,7 @@ def test_func_bottle_fake_api():
59 59
     resp = app.post('/users/', status='*')
60 60
     assert resp.status_int == 400
61 61
     assert resp.json == {
62
+        'code': None,
62 63
         'details': {
63 64
             'email_address': ['Missing data for required field.'],
64 65
             'username': ['Missing data for required field.'],

+ 1 - 0
tests/func/fake_api/test_flask.py View File

@@ -57,6 +57,7 @@ def test_func_flask_fake_api():
57 57
     resp = app.post('/users/', status='*')
58 58
     assert resp.status_int == 400
59 59
     assert resp.json == {
60
+        'code': None,
60 61
         'details': {
61 62
             'email_address': ['Missing data for required field.'],
62 63
             'username': ['Missing data for required field.'],

+ 1 - 0
tests/func/fake_api/test_pyramid.py View File

@@ -58,6 +58,7 @@ def test_func_pyramid_fake_api_doc():
58 58
     resp = app.post('/users/', status='*')
59 59
     assert resp.status_int == 400
60 60
     assert resp.json == {
61
+        'code': None,
61 62
         'details': {
62 63
             'email_address': ['Missing data for required field.'],
63 64
             'username': ['Missing data for required field.'],

+ 32 - 5
tests/unit/test_decorator.py View File

@@ -1,5 +1,9 @@
1 1
 # -*- coding: utf-8 -*-
2
+import pytest
2 3
 import typing
4
+
5
+from hapic.exception import OutputValidationException
6
+
3 7
 try:  # Python 3.5+
4 8
     from http import HTTPStatus
5 9
 except ImportError:
@@ -14,7 +18,7 @@ from hapic.decorator import InputQueryControllerWrapper
14 18
 from hapic.decorator import InputControllerWrapper
15 19
 from hapic.decorator import InputOutputControllerWrapper
16 20
 from hapic.decorator import OutputControllerWrapper
17
-from hapic.hapic import ErrorResponseSchema
21
+from hapic.error import DefaultErrorBuilder
18 22
 from hapic.processor import MarshmallowOutputProcessor
19 23
 from hapic.processor import ProcessValidationError
20 24
 from hapic.processor import ProcessorInterface
@@ -260,7 +264,7 @@ class TestExceptionHandlerControllerWrapper(Base):
260 264
         wrapper = ExceptionHandlerControllerWrapper(
261 265
             ZeroDivisionError,
262 266
             context,
263
-            schema=ErrorResponseSchema(),
267
+            error_builder=DefaultErrorBuilder(),
264 268
             http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
265 269
         )
266 270
 
@@ -275,7 +279,7 @@ class TestExceptionHandlerControllerWrapper(Base):
275 279
         assert response['original_response'] == {
276 280
             'message': 'We are testing',
277 281
             'code': None,
278
-            'detail': {},
282
+            'details': {},
279 283
         }
280 284
 
281 285
     def test_unit__exception_handled__ok__exception_error_dict(self):
@@ -288,7 +292,7 @@ class TestExceptionHandlerControllerWrapper(Base):
288 292
         wrapper = ExceptionHandlerControllerWrapper(
289 293
             MyException,
290 294
             context,
291
-            schema=ErrorResponseSchema(),
295
+            error_builder=DefaultErrorBuilder(),
292 296
             http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
293 297
         )
294 298
 
@@ -305,5 +309,28 @@ class TestExceptionHandlerControllerWrapper(Base):
305 309
         assert response['original_response'] == {
306 310
             'message': 'We are testing',
307 311
             'code': None,
308
-            'detail': {'foo': 'bar'},
312
+            'details': {'foo': 'bar'},
309 313
         }
314
+
315
+    def test_unit__exception_handler__error__error_content_malformed(self):
316
+        class MyException(Exception):
317
+            pass
318
+
319
+        class MyErrorBuilder(DefaultErrorBuilder):
320
+            def build_from_exception(self, exception: Exception) -> dict:
321
+                # this is not matching with DefaultErrorBuilder schema
322
+                return {}
323
+
324
+        context = MyContext(app=None)
325
+        wrapper = ExceptionHandlerControllerWrapper(
326
+            MyException,
327
+            context,
328
+            error_builder=MyErrorBuilder(),
329
+        )
330
+
331
+        def raise_it():
332
+            raise MyException()
333
+
334
+        wrapper = wrapper.get_wrapper(raise_it)
335
+        with pytest.raises(OutputValidationException):
336
+            wrapper()