Browse Source

reorganize and write unit tests

Bastien Sevajol 6 years ago
parent
commit
3f29ec6ec1
17 changed files with 1254 additions and 433 deletions
  1. 2 1
      .gitignore
  2. 11 11
      example_a.py
  3. 8 7
      example_b.py
  4. 42 35
      hapic/__init__.py
  5. 101 0
      hapic/buffer.py
  6. 77 0
      hapic/context.py
  7. 10 0
      hapic/data.py
  8. 261 0
      hapic/decorator.py
  9. 64 0
      hapic/description.py
  10. 8 0
      hapic/exception.py
  11. 208 379
      hapic/hapic.py
  12. 95 0
      hapic/processor.py
  13. 1 0
      hapic/util.py
  14. 4 0
      tests/base.py
  15. 103 0
      tests/unit/test_buffer.py
  16. 173 0
      tests/unit/test_decorator.py
  17. 86 0
      tests/unit/test_processor.py

+ 2 - 1
.gitignore View File

@@ -2,4 +2,5 @@
2 2
 *.pyc
3 3
 __pycache__
4 4
 *~
5
-/venv
5
+/venv
6
+.cache

+ 11 - 11
example_a.py View File

@@ -3,7 +3,7 @@ import bottle
3 3
 import hapic
4 4
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
5 5
     ErrorResponseSchema
6
-from hapic.hapic import HapicData
6
+from hapic.data import HapicData
7 7
 
8 8
 app = bottle.Bottle()
9 9
 
@@ -15,10 +15,10 @@ def bob(f):
15 15
 
16 16
 
17 17
 @hapic.with_api_doc()
18
-@hapic.ext.bottle.bottle_context()
19
-@hapic.error_schema(ErrorResponseSchema())
18
+# @hapic.ext.bottle.bottle_context()
19
+# @hapic.error_schema(ErrorResponseSchema())
20 20
 @hapic.input_path(HelloPathSchema())
21
-@hapic.output(HelloResponseSchema())
21
+@hapic.output_body(HelloResponseSchema())
22 22
 def hello(name: str, hapic_data: HapicData):
23 23
     return {
24 24
         'sentence': 'Hello !',
@@ -27,11 +27,11 @@ def hello(name: str, hapic_data: HapicData):
27 27
 
28 28
 
29 29
 @hapic.with_api_doc()
30
-@hapic.ext.bottle.bottle_context()
31
-@hapic.error_schema(ErrorResponseSchema())
30
+# @hapic.ext.bottle.bottle_context()
31
+# @hapic.error_schema(ErrorResponseSchema())
32 32
 @hapic.input_path(HelloPathSchema())
33 33
 @hapic.input_body(HelloJsonSchema())
34
-@hapic.output(HelloResponseSchema())
34
+@hapic.output_body(HelloResponseSchema())
35 35
 @bob
36 36
 def hello2(name: str, hapic_data: HapicData):
37 37
     return {
@@ -44,10 +44,10 @@ kwargs = {'validated_data': {'name': 'bob'}, 'name': 'bob'}
44 44
 
45 45
 
46 46
 @hapic.with_api_doc()
47
-@hapic.ext.bottle.bottle_context()
48
-@hapic.error_schema(ErrorResponseSchema())
49
-@hapic.output(HelloResponseSchema())
50
-def hello3(name: str, hapic_data: HapicData):
47
+# @hapic.ext.bottle.bottle_context()
48
+# @hapic.error_schema(ErrorResponseSchema())
49
+@hapic.output_body(HelloResponseSchema())
50
+def hello3(name: str):
51 51
     return {
52 52
         'sentence': 'Hello !',
53 53
         'name': name,

+ 8 - 7
example_b.py View File

@@ -2,8 +2,9 @@
2 2
 import bottle
3 3
 import hapic
4 4
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema
5
-from hapic.hapic import MarshmallowOutputProcessor, BottleContext, \
6
-    MarshmallowPathInputProcessor, MarshmallowJsonInputProcessor
5
+from hapic.hapic import MarshmallowPathInputProcessor, MarshmallowJsonInputProcessor
6
+from hapic import BottleContext
7
+from hapic.processor import MarshmallowOutputProcessor
7 8
 
8 9
 
9 10
 def bob(f):
@@ -15,7 +16,7 @@ def bob(f):
15 16
 @hapic.with_api_doc_bis()
16 17
 @bottle.route('/hello/<name>')
17 18
 @hapic.input(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
18
-@hapic.output(HelloResponseSchema(), MarshmallowOutputProcessor())
19
+@hapic.output_body(HelloResponseSchema(), MarshmallowOutputProcessor())
19 20
 @bob
20 21
 def hello(name: str):
21 22
     return "Hello {}!".format(name)
@@ -25,7 +26,7 @@ def hello(name: str):
25 26
 @bottle.route('/hello2/<name>')
26 27
 @hapic.input(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
27 28
 @hapic.input(HelloJsonSchema(), MarshmallowJsonInputProcessor(), context=BottleContext())  # nopep8
28
-@hapic.output(HelloResponseSchema())
29
+@hapic.output_body(HelloResponseSchema())
29 30
 @bob
30 31
 def hello2(name: str):
31 32
     return "Hello {}!".format(name)
@@ -33,7 +34,7 @@ def hello2(name: str):
33 34
 
34 35
 @hapic.with_api_doc_bis()
35 36
 @bottle.route('/hello3/<name>')
36
-@hapic.output(HelloResponseSchema())
37
+@hapic.output_body(HelloResponseSchema())
37 38
 def hello3(name: str):
38 39
     return "Hello {}!".format(name)
39 40
 
@@ -49,7 +50,7 @@ bottle.run(host='localhost', port=8080, debug=True)
49 50
 @hapic.input_header(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
50 51
 @hapic.input_query(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
51 52
 @hapic.input_path(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
52
-@hapic.output(HelloResponseSchema(), MarshmallowOutputProcessor())
53
+@hapic.output_body(HelloResponseSchema(), MarshmallowOutputProcessor())
53 54
 def hello(name: str, hapic_data):
54 55
     return "Hello {}!".format(name)
55 56
 
@@ -57,7 +58,7 @@ def hello(name: str, hapic_data):
57 58
 @hapic.with_api_doc_bis()
58 59
 @bottle.route('/hello/<name>')
59 60
 @hapic.input(HelloPathSchema(), MarshmallowPathInputProcessor(), context=BottleContext())  # nopep8
60
-@hapic.output(HelloResponseSchema(), MarshmallowOutputProcessor())
61
+@hapic.output_body(HelloResponseSchema(), MarshmallowOutputProcessor())
61 62
 @bob
62 63
 def hello(name: str):
63 64
     return "Hello {}!".format(name)

+ 42 - 35
hapic/__init__.py View File

@@ -1,37 +1,44 @@
1 1
 # -*- coding: utf-8 -*-
2
-# from hapic.hapic import Hapic
3
-
4
-# _hapic_default = Hapic()
5
-#
6
-# with_api_doc = _hapic_default.with_api_doc
2
+from hapic.hapic import Hapic
3
+
4
+_hapic_default = Hapic()
5
+
6
+with_api_doc = _hapic_default.with_api_doc
7
+input_headers = _hapic_default.input_headers
8
+input_body = _hapic_default.input_body
9
+input_path = _hapic_default.input_path
10
+input_query = _hapic_default.input_query
11
+input_forms = _hapic_default.input_forms
12
+output_headers = _hapic_default.output_headers
13
+output_body = _hapic_default.output_body
7 14
 # with_api_doc_bis = _hapic_default.with_api_doc_bis
8
-# generate_doc = _hapic_default.generate_doc
9
-
10
-from hapic.hapic import with_api_doc
11
-from hapic.hapic import with_api_doc_bis
12
-from hapic.hapic import generate_doc
13
-from hapic.hapic import output
14
-from hapic.hapic import input_body
15
-from hapic.hapic import input_query
16
-from hapic.hapic import input_path
17
-from hapic.hapic import input_headers
18
-from hapic.hapic import BottleContext
19
-from hapic.hapic import set_fake_default_context
20
-from hapic.hapic import error_schema
21
-
22
-
23
-class FakeSetContext(object):
24
-    def bottle_context(self):
25
-        hapic.set_fake_default_context(BottleContext())
26
-        def decorator(func):
27
-            def wrapper(*args, **kwargs):
28
-                return func(*args, **kwargs)
29
-            return wrapper
30
-        return decorator
31
-
32
-
33
-class FakeExt(object):
34
-    bottle = FakeSetContext()
35
-
36
-
37
-ext = FakeExt()
15
+generate_doc = _hapic_default.generate_doc
16
+
17
+# from hapic.hapic import with_api_doc
18
+# from hapic.hapic import with_api_doc_bis
19
+# from hapic.hapic import generate_doc
20
+# from hapic.hapic import output_body
21
+# from hapic.hapic import input_body
22
+# from hapic.hapic import input_query
23
+# from hapic.hapic import input_path
24
+# from hapic.hapic import input_headers
25
+# from hapic.context import BottleContext
26
+# from hapic.hapic import set_fake_default_context
27
+# from hapic.hapic import error_schema
28
+
29
+
30
+# class FakeSetContext(object):
31
+#     def bottle_context(self):
32
+#         hapic.set_fake_default_context(BottleContext())
33
+#         def decorator(func):
34
+#             def wrapper(*args, **kwargs):
35
+#                 return func(*args, **kwargs)
36
+#             return wrapper
37
+#         return decorator
38
+#
39
+#
40
+# class FakeExt(object):
41
+#     bottle = FakeSetContext()
42
+#
43
+#
44
+# ext = FakeExt()

+ 101 - 0
hapic/buffer.py View File

@@ -0,0 +1,101 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from hapic.description import ControllerDescription
5
+from hapic.description import InputPathDescription
6
+from hapic.description import InputQueryDescription
7
+from hapic.description import InputBodyDescription
8
+from hapic.description import InputHeadersDescription
9
+from hapic.description import InputFormsDescription
10
+from hapic.description import OutputBodyDescription
11
+from hapic.description import OutputHeadersDescription
12
+from hapic.description import ErrorDescription
13
+from hapic.exception import AlreadyDecoratedException
14
+
15
+
16
+class DecorationBuffer(object):
17
+    def __init__(self) -> None:
18
+        self._description = ControllerDescription()
19
+
20
+    def clear(self):
21
+        self._description = ControllerDescription()
22
+
23
+    def get_description(self) -> ControllerDescription:
24
+        return self._description
25
+
26
+    @property
27
+    def input_path(self) -> InputPathDescription:
28
+        return self._description.input_path
29
+
30
+    @input_path.setter
31
+    def input_path(self, description: InputPathDescription) -> None:
32
+        if self._description.input_path is not None:
33
+            raise AlreadyDecoratedException()
34
+        self._description.input_path = description
35
+
36
+    @property
37
+    def input_query(self) -> InputQueryDescription:
38
+        return self._description.input_query
39
+
40
+    @input_query.setter
41
+    def input_query(self, description: InputQueryDescription) -> None:
42
+        if self._description.input_query is not None:
43
+            raise AlreadyDecoratedException()
44
+        self._description.input_query = description
45
+
46
+    @property
47
+    def input_body(self) -> InputBodyDescription:
48
+        return self._description.input_body
49
+
50
+    @input_body.setter
51
+    def input_body(self, description: InputBodyDescription) -> None:
52
+        if self._description.input_body is not None:
53
+            raise AlreadyDecoratedException()
54
+        self._description.input_body = description
55
+
56
+    @property
57
+    def input_headers(self) -> InputHeadersDescription:
58
+        return self._description.input_headers
59
+
60
+    @input_headers.setter
61
+    def input_headers(self, description: InputHeadersDescription) -> None:
62
+        if self._description.input_headers is not None:
63
+            raise AlreadyDecoratedException()
64
+        self._description.input_headers = description
65
+
66
+    @property
67
+    def input_forms(self) -> InputFormsDescription:
68
+        return self._description.input_forms
69
+
70
+    @input_forms.setter
71
+    def input_forms(self, description: InputFormsDescription) -> None:
72
+        if self._description.input_forms is not None:
73
+            raise AlreadyDecoratedException()
74
+        self._description.input_forms = description
75
+
76
+    @property
77
+    def output_body(self) -> OutputBodyDescription:
78
+        return self._description.output_body
79
+
80
+    @output_body.setter
81
+    def output_body(self, description: OutputBodyDescription) -> None:
82
+        if self._description.output_body is not None:
83
+            raise AlreadyDecoratedException()
84
+        self._description.output_body = description
85
+
86
+    @property
87
+    def output_headers(self) -> OutputHeadersDescription:
88
+        return self._description.output_headers
89
+
90
+    @output_headers.setter
91
+    def output_headers(self, description: OutputHeadersDescription) -> None:
92
+        if self._description.output_headers is not None:
93
+            raise AlreadyDecoratedException()
94
+        self._description.output_headers = description
95
+
96
+    @property
97
+    def errors(self) -> typing.List[ErrorDescription]:
98
+        return self._description.errors
99
+
100
+    def add_error(self, description: ErrorDescription) -> None:
101
+        self._description.errors.append(description)

+ 77 - 0
hapic/context.py View File

@@ -0,0 +1,77 @@
1
+# -*- coding: utf-8 -*-
2
+import json
3
+import typing
4
+from http import HTTPStatus
5
+
6
+import bottle
7
+
8
+from hapic.exception import OutputValidationException
9
+# from hapic.hapic import _default_global_error_schema
10
+from hapic.processor import RequestParameters, ProcessValidationError
11
+
12
+
13
+class ContextInterface(object):
14
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
15
+        raise NotImplementedError()
16
+
17
+    def get_response(
18
+        self,
19
+        response: dict,
20
+        http_code: int,
21
+    ) -> typing.Any:
22
+        raise NotImplementedError()
23
+
24
+    def get_validation_error_response(
25
+        self,
26
+        error: ProcessValidationError,
27
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
28
+    ) -> typing.Any:
29
+        raise NotImplementedError()
30
+
31
+
32
+class BottleContext(ContextInterface):
33
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
34
+        return RequestParameters(
35
+            path_parameters=bottle.request.url_args,
36
+            query_parameters=bottle.request.params,
37
+            body_parameters=bottle.request.json,
38
+            form_parameters=bottle.request.forms,
39
+            header_parameters=bottle.request.headers,
40
+        )
41
+
42
+    def get_response(
43
+        self,
44
+        response: dict,
45
+        http_code: int,
46
+    ) -> bottle.HTTPResponse:
47
+        return bottle.HTTPResponse(
48
+            body=json.dumps(response),
49
+            headers=[
50
+                ('Content-Type', 'application/json'),
51
+            ],
52
+            status=http_code,
53
+        )
54
+
55
+    def get_validation_error_response(
56
+        self,
57
+        error: ProcessValidationError,
58
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
59
+    ) -> typing.Any:
60
+        # TODO
61
+        from hapic.hapic import _default_global_error_schema
62
+        unmarshall = _default_global_error_schema.dump(error)
63
+        if unmarshall.errors:
64
+            raise OutputValidationException(
65
+                'Validation error during dump of error response: {}'.format(
66
+                    str(unmarshall.errors)
67
+                )
68
+            )
69
+
70
+        return bottle.HTTPResponse(
71
+            body=json.dumps(unmarshall.data),
72
+            headers=[
73
+                ('Content-Type', 'application/json'),
74
+            ],
75
+            status=int(http_code),
76
+        )
77
+

+ 10 - 0
hapic/data.py View File

@@ -0,0 +1,10 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+
4
+class HapicData(object):
5
+    def __init__(self):
6
+        self.body = {}
7
+        self.path = {}
8
+        self.query = {}
9
+        self.headers = {}
10
+        self.forms = {}

+ 261 - 0
hapic/decorator.py View File

@@ -0,0 +1,261 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+from http import HTTPStatus
4
+
5
+from hapic.data import HapicData
6
+from hapic.description import ControllerDescription
7
+from hapic.exception import ProcessException
8
+from hapic.context import ContextInterface
9
+from hapic.processor import ProcessorInterface
10
+from hapic.processor import RequestParameters
11
+
12
+
13
+class ControllerWrapper(object):
14
+    def __init__(
15
+        self,
16
+        context: ContextInterface,
17
+        processor: ProcessorInterface,
18
+        error_http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
19
+        default_http_code: HTTPStatus=HTTPStatus.OK,
20
+    ) -> None:
21
+        self.context = context
22
+        self.processor = processor
23
+        self.error_http_code = error_http_code
24
+        self.default_http_code = default_http_code
25
+
26
+    def before_wrapped_func(
27
+        self,
28
+        func_args: typing.Tuple[typing.Any, ...],
29
+        func_kwargs: typing.Dict[str, typing.Any],
30
+    ) -> typing.Union[None, typing.Any]:
31
+        pass
32
+
33
+    def after_wrapped_function(self, response: typing.Any) -> typing.Any:
34
+        return response
35
+
36
+    def get_wrapper(
37
+        self,
38
+        func: 'typing.Callable[..., typing.Any]',
39
+    ) -> 'typing.Callable[..., typing.Any]':
40
+        def wrapper(*args, **kwargs) -> typing.Any:
41
+            # Note: Design of before_wrapped_func can be to update kwargs
42
+            # by reference here
43
+            replacement_response = self.before_wrapped_func(args, kwargs)
44
+            if replacement_response:
45
+                return replacement_response
46
+
47
+            response = func(*args, **kwargs)
48
+            new_response = self.after_wrapped_function(response)
49
+            return new_response
50
+        return wrapper
51
+
52
+
53
+class InputControllerWrapper(ControllerWrapper):
54
+    def before_wrapped_func(
55
+        self,
56
+        func_args: typing.Tuple[typing.Any, ...],
57
+        func_kwargs: typing.Dict[str, typing.Any],
58
+    ) -> typing.Any:
59
+        # Retrieve hapic_data instance or create new one
60
+        # hapic_data is given though decorators
61
+        # Important note here: func_kwargs is update by reference !
62
+        hapic_data = self.ensure_hapic_data(func_kwargs)
63
+        request_parameters = self.get_request_parameters(
64
+            func_args,
65
+            func_kwargs,
66
+        )
67
+
68
+        try:
69
+            processed_data = self.get_processed_data(request_parameters)
70
+            self.update_hapic_data(hapic_data, processed_data)
71
+        except ProcessException:
72
+            error_response = self.get_error_response(request_parameters)
73
+            return error_response
74
+
75
+    @classmethod
76
+    def ensure_hapic_data(
77
+        cls,
78
+        func_kwargs: typing.Dict[str, typing.Any],
79
+    ) -> HapicData:
80
+        # TODO: Permit other name than "hapic_data" ?
81
+        try:
82
+            return func_kwargs['hapic_data']
83
+        except KeyError:
84
+            hapic_data = HapicData()
85
+            func_kwargs['hapic_data'] = hapic_data
86
+            return hapic_data
87
+
88
+    def get_request_parameters(
89
+        self,
90
+        func_args: typing.Tuple[typing.Any, ...],
91
+        func_kwargs: typing.Dict[str, typing.Any],
92
+    ) -> RequestParameters:
93
+        return self.context.get_request_parameters(
94
+            *func_args,
95
+            **func_kwargs
96
+        )
97
+
98
+    def get_processed_data(
99
+        self,
100
+        request_parameters: RequestParameters,
101
+    ) -> typing.Any:
102
+        raise NotImplementedError()
103
+
104
+    def update_hapic_data(
105
+        self,
106
+        hapic_data: HapicData,
107
+        processed_data: typing.Dict[str, typing.Any],
108
+    ) -> None:
109
+        raise NotImplementedError()
110
+
111
+    def get_error_response(
112
+        self,
113
+        request_parameters: RequestParameters,
114
+    ) -> typing.Any:
115
+        error = self.processor.get_validation_error(
116
+            request_parameters.body_parameters,
117
+        )
118
+        error_response = self.context.get_validation_error_response(
119
+            error,
120
+            http_code=self.error_http_code,
121
+        )
122
+        return error_response
123
+
124
+
125
+class OutputControllerWrapper(ControllerWrapper):
126
+    def get_error_response(
127
+        self,
128
+        response: typing.Any,
129
+    ) -> typing.Any:
130
+        error = self.processor.get_validation_error(response)
131
+        error_response = self.context.get_validation_error_response(
132
+            error,
133
+            http_code=self.error_http_code,
134
+        )
135
+        return error_response
136
+
137
+    def after_wrapped_function(self, response: typing.Any) -> typing.Any:
138
+        try:
139
+            processed_response = self.processor.process(response)
140
+            prepared_response = self.context.get_response(
141
+                processed_response,
142
+                self.default_http_code,
143
+            )
144
+            return prepared_response
145
+        except ProcessException:
146
+            # TODO: ici ou ailleurs: il faut pas forcement donner le detail
147
+            # de l'erreur (mode debug par exemple)
148
+            error_response = self.get_error_response(response)
149
+            return error_response
150
+
151
+
152
+class DecoratedController(object):
153
+    def __init__(
154
+        self,
155
+        reference: 'typing.Callable[..., typing.Any]',
156
+        description: ControllerDescription,
157
+    ) -> None:
158
+        self._reference = reference
159
+        self._description = description
160
+
161
+    @property
162
+    def reference(self) -> 'typing.Callable[..., typing.Any]':
163
+        return self._reference
164
+
165
+    @property
166
+    def description(self) -> ControllerDescription:
167
+        return self._description
168
+
169
+
170
+class OutputBodyControllerWrapper(OutputControllerWrapper):
171
+    pass
172
+
173
+
174
+class OutputHeadersControllerWrapper(OutputControllerWrapper):
175
+    # TODO: write me
176
+    pass
177
+
178
+
179
+class InputPathControllerWrapper(InputControllerWrapper):
180
+    def update_hapic_data(
181
+        self, hapic_data: HapicData,
182
+        processed_data: typing.Any,
183
+    ) -> None:
184
+        hapic_data.path = processed_data
185
+
186
+    def get_processed_data(
187
+        self,
188
+        request_parameters: RequestParameters,
189
+    ) -> typing.Any:
190
+        processed_data = self.processor.process(
191
+            request_parameters.path_parameters,
192
+        )
193
+        return processed_data
194
+
195
+
196
+class InputQueryControllerWrapper(InputControllerWrapper):
197
+    def update_hapic_data(
198
+        self, hapic_data: HapicData,
199
+        processed_data: typing.Any,
200
+    ) -> None:
201
+        hapic_data.query = processed_data
202
+
203
+    def get_processed_data(
204
+        self,
205
+        request_parameters: RequestParameters,
206
+    ) -> typing.Any:
207
+        processed_data = self.processor.process(
208
+            request_parameters.query_parameters,
209
+        )
210
+        return processed_data
211
+
212
+
213
+class InputBodyControllerWrapper(InputControllerWrapper):
214
+    def update_hapic_data(
215
+        self, hapic_data: HapicData,
216
+        processed_data: typing.Any,
217
+    ) -> None:
218
+        hapic_data.body = processed_data
219
+
220
+    def get_processed_data(
221
+        self,
222
+        request_parameters: RequestParameters,
223
+    ) -> typing.Any:
224
+        processed_data = self.processor.process(
225
+            request_parameters.body_parameters,
226
+        )
227
+        return processed_data
228
+
229
+
230
+class InputHeadersControllerWrapper(InputControllerWrapper):
231
+    def update_hapic_data(
232
+        self, hapic_data: HapicData,
233
+        processed_data: typing.Any,
234
+    ) -> None:
235
+        hapic_data.headers = processed_data
236
+
237
+    def get_processed_data(
238
+        self,
239
+        request_parameters: RequestParameters,
240
+    ) -> typing.Any:
241
+        processed_data = self.processor.process(
242
+            request_parameters.header_parameters,
243
+        )
244
+        return processed_data
245
+
246
+
247
+class InputFormsControllerWrapper(InputControllerWrapper):
248
+    def update_hapic_data(
249
+        self, hapic_data: HapicData,
250
+        processed_data: typing.Any,
251
+    ) -> None:
252
+        hapic_data.forms = processed_data
253
+
254
+    def get_processed_data(
255
+        self,
256
+        request_parameters: RequestParameters,
257
+    ) -> typing.Any:
258
+        processed_data = self.processor.process(
259
+            request_parameters.form_parameters,
260
+        )
261
+        return processed_data

+ 64 - 0
hapic/description.py View File

@@ -0,0 +1,64 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+if typing.TYPE_CHECKING:
5
+    from hapic.decorator import ControllerWrapper
6
+
7
+
8
+class Description(object):
9
+    def __init__(self, wrapper: 'ControllerWrapper') -> None:
10
+        self.wrapper = wrapper
11
+
12
+
13
+class InputPathDescription(Description):
14
+    pass
15
+
16
+
17
+class InputQueryDescription(Description):
18
+    pass
19
+
20
+
21
+class InputBodyDescription(Description):
22
+    pass
23
+
24
+
25
+class InputHeadersDescription(Description):
26
+    pass
27
+
28
+
29
+class InputFormsDescription(Description):
30
+    pass
31
+
32
+
33
+class OutputBodyDescription(Description):
34
+    pass
35
+
36
+
37
+class OutputHeadersDescription(Description):
38
+    pass
39
+
40
+
41
+class ErrorDescription(Description):
42
+    pass
43
+
44
+
45
+class ControllerDescription(object):
46
+    def __init__(
47
+        self,
48
+        input_path: InputPathDescription=None,
49
+        input_query: InputQueryDescription=None,
50
+        input_body: InputBodyDescription=None,
51
+        input_headers: InputHeadersDescription=None,
52
+        input_forms: InputFormsDescription=None,
53
+        output_body: OutputBodyDescription=None,
54
+        output_headers: OutputHeadersDescription=None,
55
+        errors: typing.List[ErrorDescription]=None,
56
+    ):
57
+        self.input_path = input_path
58
+        self.input_query = input_query
59
+        self.input_body = input_body
60
+        self.input_headers = input_headers
61
+        self.input_forms = input_forms
62
+        self.output_body = output_body
63
+        self.output_headers = output_headers
64
+        self.errors = errors or []

+ 8 - 0
hapic/exception.py View File

@@ -9,6 +9,14 @@ class WorkflowException(HapicException):
9 9
     pass
10 10
 
11 11
 
12
+class DecorationException(HapicException):
13
+    pass
14
+
15
+
16
+class AlreadyDecoratedException(DecorationException):
17
+    pass
18
+
19
+
12 20
 class ProcessException(HapicException):
13 21
     pass
14 22
 

+ 208 - 379
hapic/hapic.py View File

@@ -1,422 +1,251 @@
1 1
 # -*- coding: utf-8 -*-
2
-import json
3 2
 import typing
4 3
 from http import HTTPStatus
5 4
 
6
-import functools
7
-
8 5
 import bottle
9
-
10
-# TODO: Gérer les erreurs de schema
11
-# TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb)
12
-
6
+import functools
13 7
 
14 8
 # CHANGE
15
-from hapic.exception import InputValidationException, \
16
-    OutputValidationException, InputWorkflowException, ProcessException
9
+import marshmallow
10
+
11
+from hapic.buffer import DecorationBuffer
12
+from hapic.context import ContextInterface, BottleContext
13
+from hapic.decorator import DecoratedController
14
+from hapic.decorator import InputBodyControllerWrapper
15
+from hapic.decorator import InputHeadersControllerWrapper
16
+from hapic.decorator import InputPathControllerWrapper
17
+from hapic.decorator import InputQueryControllerWrapper
18
+from hapic.decorator import OutputBodyControllerWrapper
19
+from hapic.decorator import OutputHeadersControllerWrapper
20
+from hapic.description import InputBodyDescription
21
+from hapic.description import InputFormsDescription
22
+from hapic.description import InputHeadersDescription
23
+from hapic.description import InputPathDescription
24
+from hapic.description import InputQueryDescription
25
+from hapic.description import OutputBodyDescription
26
+from hapic.description import OutputHeadersDescription
27
+from hapic.processor import ProcessorInterface, MarshmallowInputProcessor
17 28
 
18 29
 flatten = lambda l: [item for sublist in l for item in sublist]
19 30
 
31
+# TODO: Gérer les erreurs de schema
32
+# TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb)
33
+# TODO: Confusion nommage body/json/forms
20 34
 
21
-_waiting = {}
22
-_endpoints = {}
23
-_default_global_context = None
24
-_default_global_error_schema = None
25
-
26
-
27
-def error_schema(schema):
28
-    global _default_global_error_schema
29
-    _default_global_error_schema = schema
30
-
31
-    def decorator(func):
32
-        @functools.wraps(func)
33
-        def wrapper(*args, **kwargs):
34
-            return func(*args, **kwargs)
35
-        return wrapper
36
-    return decorator
37
-
38
-
39
-def set_fake_default_context(context):
40
-    global _default_global_context
41
-    _default_global_context = context
42
-
43
-
44
-def _register(func):
45
-    assert func not in _endpoints
46
-    global _waiting
47
-
48
-    _endpoints[func] = _waiting
49
-    _waiting = {}
50
-
51
-
52
-def with_api_doc():
53
-    def decorator(func):
54
-
55
-        @functools.wraps(func)
56
-        def wrapper(*args, **kwargs):
57
-            return func(*args, **kwargs)
58
-
59
-        _register(wrapper)
60
-        return wrapper
61
-
62
-    return decorator
63
-
64
-
65
-def with_api_doc_bis():
66
-    def decorator(func):
67
-
68
-        @functools.wraps(func)
69
-        def wrapper(*args, **kwargs):
70
-            return func(*args, **kwargs)
35
+# _waiting = {}
36
+# _endpoints = {}
37
+# TODO NOW: C'est un gros gros fake !
38
+class ErrorResponseSchema(marshmallow.Schema):
39
+    error_message = marshmallow.fields.String(required=True)
40
+    error_details = marshmallow.fields.Dict(required=True)
71 41
 
72
-        _register(func)
73
-        return wrapper
42
+_default_global_context = BottleContext()
43
+_default_global_error_schema = ErrorResponseSchema()
74 44
 
75
-    return decorator
76 45
 
46
+class Hapic(object):
47
+    def __init__(self):
48
+        self._buffer = DecorationBuffer()
49
+        self._controllers = []
77 50
 
78
-def generate_doc(app=None):
79
-    # TODO @Damien bottle specific code !
80
-    app = app or bottle.default_app()
51
+    def with_api_doc(self):
52
+        def decorator(func):
81 53
 
82
-    route_by_callbacks = []
83
-    routes = flatten(app.router.dyna_routes.values())
84
-    for path, path_regex, route, func_ in routes:
85
-        route_by_callbacks.append(route.callback)
54
+            @functools.wraps(func)
55
+            def wrapper(*args, **kwargs):
56
+                return func(*args, **kwargs)
86 57
 
87
-    for func, descriptions in _endpoints.items():
88
-        routes = flatten(app.router.dyna_routes.values())
89
-        for path, path_regex, route, func_ in routes:
90
-            if route.callback == func:
91
-                print(route.method, path, descriptions)
92
-                continue
58
+            description = self._buffer.get_description()
59
+            decorated_controller = DecoratedController(
60
+                reference=wrapper,
61
+                description=description,
62
+            )
63
+            self._buffer.clear()
64
+            self._controllers.append(decorated_controller)
65
+            return wrapper
93 66
 
67
+        return decorator
94 68
 
95
-class RequestParameters(object):
96
-    def __init__(
97
-        self,
98
-        path_parameters,
99
-        query_parameters,
100
-        body_parameters,
101
-        form_parameters,
102
-        header_parameters,
103
-    ):
104
-        self.path_parameters = path_parameters
105
-        self.query_parameters = query_parameters
106
-        self.body_parameters = body_parameters
107
-        self.form_parameters = form_parameters
108
-        self.header_parameters = header_parameters
109
-
110
-
111
-class ProcessValidationError(object):
112
-    def __init__(
69
+    def output_body(
113 70
         self,
114
-        error_message: str,
115
-        error_details: dict,
116
-    ) -> None:
117
-        self.error_message = error_message
118
-        self.error_details = error_details
119
-
71
+        schema: typing.Any,
72
+        processor: ProcessorInterface = None,
73
+        context: ContextInterface = None,
74
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
75
+        default_http_code: HTTPStatus = HTTPStatus.OK,
76
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
77
+        processor = processor or MarshmallowInputProcessor()
78
+        processor.schema = schema
79
+        context = context or _default_global_context
80
+
81
+        decoration = OutputBodyControllerWrapper(
82
+            context=context,
83
+            processor=processor,
84
+            error_http_code=error_http_code,
85
+            default_http_code=default_http_code,
86
+        )
120 87
 
121
-class ContextInterface(object):
122
-    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
123
-        raise NotImplementedError()
88
+        def decorator(func):
89
+            self._buffer.output_body = OutputBodyDescription(decoration)
90
+            return decoration.get_wrapper(func)
91
+        return decorator
124 92
 
125
-    def get_response(
93
+    def output_headers(
126 94
         self,
127
-        response: dict,
128
-        http_code: int,
129
-    ) -> typing.Any:
130
-        raise NotImplementedError()
131
-
132
-    def get_validation_error_response(
133
-        self,
134
-        error: ProcessValidationError,
135
-        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
136
-    ) -> typing.Any:
137
-        raise NotImplementedError()
138
-
139
-
140
-class BottleContext(ContextInterface):
141
-    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
142
-        return RequestParameters(
143
-            path_parameters=bottle.request.url_args,
144
-            query_parameters=bottle.request.params,
145
-            body_parameters=bottle.request.json,
146
-            form_parameters=bottle.request.forms,
147
-            header_parameters=bottle.request.headers,
95
+        schema: typing.Any,
96
+        processor: ProcessorInterface = None,
97
+        context: ContextInterface = None,
98
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
99
+        default_http_code: HTTPStatus = HTTPStatus.OK,
100
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
101
+        processor = processor or MarshmallowInputProcessor()
102
+        processor.schema = schema
103
+        context = context or _default_global_context
104
+
105
+        decoration = OutputHeadersControllerWrapper(
106
+            context=context,
107
+            processor=processor,
108
+            error_http_code=error_http_code,
109
+            default_http_code=default_http_code,
148 110
         )
149 111
 
150
-    def get_response(
151
-        self,
152
-        response: dict,
153
-        http_code: int,
154
-    ) -> bottle.HTTPResponse:
155
-        return bottle.HTTPResponse(
156
-            body=json.dumps(response),
157
-            headers=[
158
-                ('Content-Type', 'application/json'),
159
-            ],
160
-            status=http_code,
161
-        )
112
+        def decorator(func):
113
+            self._buffer.output_headers = OutputHeadersDescription(decoration)
114
+            return decoration.get_wrapper(func)
115
+        return decorator
162 116
 
163
-    def get_validation_error_response(
117
+    def input_headers(
164 118
         self,
165
-        error: ProcessValidationError,
166
-        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
167
-    ) -> typing.Any:
168
-        unmarshall = _default_global_error_schema.dump(error)
169
-        if unmarshall.errors:
170
-            raise OutputValidationException(
171
-                'Validation error during dump of error response: {}'.format(
172
-                    str(unmarshall.errors)
173
-                )
174
-            )
175
-
176
-        return bottle.HTTPResponse(
177
-            body=json.dumps(unmarshall.data),
178
-            headers=[
179
-                ('Content-Type', 'application/json'),
180
-            ],
181
-            status=int(http_code),
119
+        schema: typing.Any,
120
+        processor: ProcessorInterface = None,
121
+        context: ContextInterface = None,
122
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
123
+        default_http_code: HTTPStatus = HTTPStatus.OK,
124
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
125
+        processor = processor or MarshmallowInputProcessor()
126
+        processor.schema = schema
127
+        context = context or _default_global_context
128
+
129
+        decoration = InputHeadersControllerWrapper(
130
+            context=context,
131
+            processor=processor,
132
+            error_http_code=error_http_code,
133
+            default_http_code=default_http_code,
182 134
         )
183 135
 
136
+        def decorator(func):
137
+            self._buffer.input_headers = InputHeadersDescription(decoration)
138
+            return decoration.get_wrapper(func)
139
+        return decorator
184 140
 
185
-class OutputProcessorInterface(object):
186
-    def __init__(self):
187
-        self.schema = None
188
-
189
-    def process(self, value):
190
-        raise NotImplementedError
191
-
192
-    def get_validation_error(
141
+    def input_path(
193 142
         self,
194
-        request_context: RequestParameters,
195
-    ) -> ProcessValidationError:
196
-        raise NotImplementedError
197
-
198
-
199
-class InputProcessorInterface(object):
200
-    def __init__(self):
201
-        self.schema = None
143
+        schema: typing.Any,
144
+        processor: ProcessorInterface = None,
145
+        context: ContextInterface = None,
146
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
147
+        default_http_code: HTTPStatus = HTTPStatus.OK,
148
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
149
+        processor = processor or MarshmallowInputProcessor()
150
+        processor.schema = schema
151
+        context = context or _default_global_context
152
+
153
+        decoration = InputPathControllerWrapper(
154
+            context=context,
155
+            processor=processor,
156
+            error_http_code=error_http_code,
157
+            default_http_code=default_http_code,
158
+        )
202 159
 
203
-    def process(
204
-        self,
205
-        request_context: RequestParameters,
206
-    ) -> typing.Any:
207
-        raise NotImplementedError
160
+        def decorator(func):
161
+            self._buffer.input_path = InputPathDescription(decoration)
162
+            return decoration.get_wrapper(func)
163
+        return decorator
208 164
 
209
-    def get_validation_error(
165
+    def input_query(
210 166
         self,
211
-        request_context: RequestParameters,
212
-    ) -> ProcessValidationError:
213
-        raise NotImplementedError
214
-
215
-
216
-class MarshmallowOutputProcessor(OutputProcessorInterface):
217
-    def process(self, data: typing.Any):
218
-        unmarshall = self.schema.dump(data)
219
-        if unmarshall.errors:
220
-            raise InputValidationException(
221
-                'Error when validate input: {}'.format(
222
-                    str(unmarshall.errors),
223
-                )
224
-            )
225
-
226
-        return unmarshall.data
227
-
228
-    def get_validation_error(self, data: dict) -> ProcessValidationError:
229
-        marshmallow_errors = self.schema.dump(data).errors
230
-        return ProcessValidationError(
231
-            error_message='Validation error of output data',
232
-            error_details=marshmallow_errors,
167
+        schema: typing.Any,
168
+        processor: ProcessorInterface = None,
169
+        context: ContextInterface = None,
170
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
171
+        default_http_code: HTTPStatus = HTTPStatus.OK,
172
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
173
+        processor = processor or MarshmallowInputProcessor()
174
+        processor.schema = schema
175
+        context = context or _default_global_context
176
+
177
+        decoration = InputQueryControllerWrapper(
178
+            context=context,
179
+            processor=processor,
180
+            error_http_code=error_http_code,
181
+            default_http_code=default_http_code,
233 182
         )
234 183
 
184
+        def decorator(func):
185
+            self._buffer.input_query = InputQueryDescription(decoration)
186
+            return decoration.get_wrapper(func)
187
+        return decorator
235 188
 
236
-class MarshmallowInputProcessor(OutputProcessorInterface):
237
-    def process(self, data: dict):
238
-        unmarshall = self.schema.load(data)
239
-        if unmarshall.errors:
240
-            raise OutputValidationException(
241
-                'Error when validate ouput: {}'.format(
242
-                    str(unmarshall.errors),
243
-                )
244
-            )
245
-
246
-        return unmarshall.data
247
-
248
-    def get_validation_error(self, data: dict) -> ProcessValidationError:
249
-        marshmallow_errors = self.schema.load(data).errors
250
-        return ProcessValidationError(
251
-            error_message='Validation error of input data',
252
-            error_details=marshmallow_errors,
189
+    def input_body(
190
+        self,
191
+        schema: typing.Any,
192
+        processor: ProcessorInterface = None,
193
+        context: ContextInterface = None,
194
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
195
+        default_http_code: HTTPStatus = HTTPStatus.OK,
196
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
197
+        processor = processor or MarshmallowInputProcessor()
198
+        processor.schema = schema
199
+        context = context or _default_global_context
200
+
201
+        decoration = InputBodyControllerWrapper(
202
+            context=context,
203
+            processor=processor,
204
+            error_http_code=error_http_code,
205
+            default_http_code=default_http_code,
253 206
         )
254 207
 
208
+        def decorator(func):
209
+            self._buffer.input_body = InputBodyDescription(decoration)
210
+            return decoration.get_wrapper(func)
211
+        return decorator
255 212
 
256
-class HapicData(object):
257
-    def __init__(self):
258
-        self.body = {}
259
-        self.path = {}
260
-        self.query = {}
261
-        self.headers = {}
262
-
263
-
264
-# TODO: Il faut un output_body et un output_header
265
-def output(
266
-    schema,
267
-    processor: OutputProcessorInterface=None,
268
-    context: ContextInterface=None,
269
-    default_http_code=200,
270
-    default_error_code=500,
271
-):
272
-    processor = processor or MarshmallowOutputProcessor()
273
-    processor.schema = schema
274
-    context = context or _default_global_context
275
-
276
-    def decorator(func):
277
-        # @functools.wraps(func)
278
-        def wrapper(*args, **kwargs):
279
-            raw_response = func(*args, **kwargs)
280
-            processed_response = processor.process(raw_response)
281
-            prepared_response = context.get_response(
282
-                processed_response,
283
-                default_http_code,
284
-            )
285
-            return prepared_response
286
-
287
-        _waiting['output'] = schema
288
-
289
-        return wrapper
290
-    return decorator
291
-
292
-# TODO: raccourcis 'input' tout court ?
293
-def input_body(
294
-    schema,
295
-    processor: InputProcessorInterface=None,
296
-    context: ContextInterface=None,
297
-    error_http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
298
-):
299
-    processor = processor or MarshmallowInputProcessor()
300
-    processor.schema = schema
301
-    context = context or _default_global_context
302
-
303
-    def decorator(func):
304
-
305
-        # @functools.wraps(func)
306
-        def wrapper(*args, **kwargs):
307
-            updated_kwargs = {'hapic_data': HapicData()}
308
-            updated_kwargs.update(kwargs)
309
-            hapic_data = updated_kwargs['hapic_data']
310
-
311
-            request_parameters = context.get_request_parameters(
312
-                *args,
313
-                **updated_kwargs
314
-            )
315
-
316
-            try:
317
-                hapic_data.body = processor.process(
318
-                    request_parameters.body_parameters,
319
-                )
320
-            except ProcessException:
321
-                error = processor.get_validation_error(
322
-                    request_parameters.body_parameters,
323
-                )
324
-                error_response = context.get_validation_error_response(
325
-                    error,
326
-                    http_code=error_http_code,
327
-                )
328
-                return error_response
329
-
330
-            return func(*args, **updated_kwargs)
331
-
332
-        _waiting.setdefault('input', []).append(schema)
333
-
334
-        return wrapper
335
-    return decorator
336
-
337
-
338
-def input_path(
339
-    schema,
340
-    processor: InputProcessorInterface=None,
341
-    context: ContextInterface=None,
342
-    error_http_code=400,
343
-):
344
-    processor = processor or MarshmallowInputProcessor()
345
-    processor.schema = schema
346
-    context = context or _default_global_context
347
-
348
-    def decorator(func):
349
-
350
-        # @functools.wraps(func)
351
-        def wrapper(*args, **kwargs):
352
-            updated_kwargs = {'hapic_data': HapicData()}
353
-            updated_kwargs.update(kwargs)
354
-            hapic_data = updated_kwargs['hapic_data']
355
-
356
-            request_parameters = context.get_request_parameters(*args, **updated_kwargs)
357
-            hapic_data.path = processor.process(request_parameters.path_parameters)
358
-
359
-            return func(*args, **updated_kwargs)
360
-
361
-        _waiting.setdefault('input', []).append(schema)
362
-
363
-        return wrapper
364
-    return decorator
365
-
366
-
367
-def input_query(
368
-    schema,
369
-    processor: InputProcessorInterface=None,
370
-    context: ContextInterface=None,
371
-    error_http_code=400,
372
-):
373
-    processor = processor or MarshmallowInputProcessor()
374
-    processor.schema = schema
375
-    context = context or _default_global_context
376
-
377
-    def decorator(func):
378
-
379
-        # @functools.wraps(func)
380
-        def wrapper(*args, **kwargs):
381
-            updated_kwargs = {'hapic_data': HapicData()}
382
-            updated_kwargs.update(kwargs)
383
-            hapic_data = updated_kwargs['hapic_data']
384
-
385
-            request_parameters = context.get_request_parameters(*args, **updated_kwargs)
386
-            hapic_data.query = processor.process(request_parameters.query_parameters)
387
-
388
-            return func(*args, **updated_kwargs)
389
-
390
-        _waiting.setdefault('input', []).append(schema)
391
-
392
-        return wrapper
393
-    return decorator
394
-
395
-
396
-def input_headers(
397
-    schema,
398
-    processor: InputProcessorInterface,
399
-    context: ContextInterface=None,
400
-    error_http_code=400,
401
-):
402
-    processor = processor or MarshmallowInputProcessor()
403
-    processor.schema = schema
404
-    context = context or _default_global_context
405
-
406
-    def decorator(func):
407
-
408
-        # @functools.wraps(func)
409
-        def wrapper(*args, **kwargs):
410
-            updated_kwargs = {'hapic_data': HapicData()}
411
-            updated_kwargs.update(kwargs)
412
-            hapic_data = updated_kwargs['hapic_data']
413
-
414
-            request_parameters = context.get_request_parameters(*args, **updated_kwargs)
415
-            hapic_data.headers = processor.process(request_parameters.header_parameters)
213
+    def input_forms(
214
+        self,
215
+        schema: typing.Any,
216
+        processor: ProcessorInterface=None,
217
+        context: ContextInterface=None,
218
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
219
+        default_http_code: HTTPStatus = HTTPStatus.OK,
220
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
221
+        processor = processor or MarshmallowInputProcessor()
222
+        processor.schema = schema
223
+        context = context or _default_global_context
224
+
225
+        decoration = InputBodyControllerWrapper(
226
+            context=context,
227
+            processor=processor,
228
+            error_http_code=error_http_code,
229
+            default_http_code=default_http_code,
230
+        )
416 231
 
417
-            return func(*args, **updated_kwargs)
232
+        def decorator(func):
233
+            self._buffer.input_forms = InputFormsDescription(decoration)
234
+            return decoration.get_wrapper(func)
235
+        return decorator
418 236
 
419
-        _waiting.setdefault('input', []).append(schema)
237
+    def generate_doc(self, app=None):
238
+        # TODO @Damien bottle specific code !
239
+        app = app or bottle.default_app()
420 240
 
421
-        return wrapper
422
-    return decorator
241
+        route_by_callbacks = []
242
+        routes = flatten(app.router.dyna_routes.values())
243
+        for path, path_regex, route, func_ in routes:
244
+            route_by_callbacks.append(route.callback)
245
+
246
+        for description in self._controllers:
247
+            for path, path_regex, route, func_ in routes:
248
+                if route.callback == description.reference:
249
+                    # TODO: use description to feed apispec
250
+                    print(route.method, path, description)
251
+                    continue

+ 95 - 0
hapic/processor.py View File

@@ -0,0 +1,95 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from hapic.exception import InputValidationException, OutputValidationException
5
+
6
+
7
+class RequestParameters(object):
8
+    def __init__(
9
+        self,
10
+        path_parameters,
11
+        query_parameters,
12
+        body_parameters,
13
+        form_parameters,
14
+        header_parameters,
15
+    ):
16
+        self.path_parameters = path_parameters
17
+        self.query_parameters = query_parameters
18
+        self.body_parameters = body_parameters
19
+        self.form_parameters = form_parameters
20
+        self.header_parameters = header_parameters
21
+
22
+
23
+class ProcessValidationError(object):
24
+    def __init__(
25
+        self,
26
+        error_message: str,
27
+        error_details: dict,
28
+    ) -> None:
29
+        self.error_message = error_message
30
+        self.error_details = error_details
31
+
32
+
33
+class ProcessorInterface(object):
34
+    def __init__(self):
35
+        self.schema = None
36
+
37
+    def process(self, value):
38
+        raise NotImplementedError
39
+
40
+    def get_validation_error(
41
+        self,
42
+        request_context: RequestParameters,
43
+    ) -> ProcessValidationError:
44
+        raise NotImplementedError
45
+
46
+
47
+class InputProcessor(ProcessorInterface):
48
+    pass
49
+
50
+
51
+class OutputProcessor(ProcessorInterface):
52
+    pass
53
+
54
+
55
+class MarshmallowOutputProcessor(OutputProcessor):
56
+    def process(self, data: typing.Any):
57
+        unmarshall = self.schema.dump(data)
58
+        # TODO: Il n'y a jamais rien dans le error au dump. il faut check le
59
+        # data au travers de .validate
60
+        if unmarshall.errors:
61
+            raise InputValidationException(
62
+                'Error when validate input: {}'.format(
63
+                    str(unmarshall.errors),
64
+                )
65
+            )
66
+
67
+        return unmarshall.data
68
+
69
+    def get_validation_error(self, data: dict) -> ProcessValidationError:
70
+        marshmallow_errors = self.schema.dump(data).errors
71
+        return ProcessValidationError(
72
+            error_message='Validation error of output data',
73
+            error_details=marshmallow_errors,
74
+        )
75
+
76
+
77
+class MarshmallowInputProcessor(OutputProcessor):
78
+    def process(self, data: dict):
79
+        unmarshall = self.schema.load(data)
80
+        if unmarshall.errors:
81
+            raise OutputValidationException(
82
+                'Error when validate ouput: {}'.format(
83
+                    str(unmarshall.errors),
84
+                )
85
+            )
86
+
87
+        return unmarshall.data
88
+
89
+    def get_validation_error(self, data: dict) -> ProcessValidationError:
90
+        marshmallow_errors = self.schema.load(data).errors
91
+        return ProcessValidationError(
92
+            error_message='Validation error of input data',
93
+            error_details=marshmallow_errors,
94
+        )
95
+

+ 1 - 0
hapic/util.py View File

@@ -0,0 +1 @@
1
+# -*- coding: utf-8 -*-

+ 4 - 0
tests/base.py View File

@@ -0,0 +1,4 @@
1
+# -*- coding: utf-8 -*-
2
+
3
+class Base(object):
4
+    pass

+ 103 - 0
tests/unit/test_buffer.py View File

@@ -0,0 +1,103 @@
1
+# -*- coding: utf-8 -*-
2
+from unittest import mock
3
+
4
+import pytest
5
+
6
+from hapic.buffer import DecorationBuffer
7
+from hapic.description import InputPathDescription
8
+from hapic.description import InputQueryDescription
9
+from hapic.description import InputBodyDescription
10
+from hapic.description import InputHeadersDescription
11
+from hapic.description import InputFormsDescription
12
+from hapic.description import OutputBodyDescription
13
+from hapic.description import OutputHeadersDescription
14
+from hapic.description import ErrorDescription
15
+from hapic.exception import AlreadyDecoratedException
16
+from tests.base import Base
17
+
18
+fake_controller_wrapper = mock.MagicMock()
19
+
20
+
21
+class TestBuffer(Base):
22
+    def test_unit__buffer_usage__ok__set_descriptions(self):
23
+        buffer = DecorationBuffer()
24
+
25
+        input_path_description = InputPathDescription(fake_controller_wrapper)
26
+        input_query_description = InputQueryDescription(fake_controller_wrapper)  # nopep8
27
+        input_body_description = InputBodyDescription(fake_controller_wrapper)
28
+        input_headers_description = InputHeadersDescription(fake_controller_wrapper)  # nopep8
29
+        input_forms_description = InputFormsDescription(fake_controller_wrapper)  # nopep8
30
+        output_headers_description = OutputHeadersDescription(fake_controller_wrapper)  # nopep8
31
+        output_body_description = OutputBodyDescription(fake_controller_wrapper)  # nopep8
32
+        error_description = ErrorDescription(fake_controller_wrapper)
33
+
34
+        buffer.input_path = input_path_description
35
+        buffer.input_query = input_query_description
36
+        buffer.input_body = input_body_description
37
+        buffer.input_headers = input_headers_description
38
+        buffer.input_forms = input_forms_description
39
+        buffer.output_headers = output_headers_description
40
+        buffer.output_body = output_body_description
41
+        buffer.add_error(error_description)
42
+
43
+        description = buffer.get_description()
44
+        assert description.input_path == input_path_description
45
+        assert description.input_query == input_query_description
46
+        assert description.input_body == input_body_description
47
+        assert description.input_headers == input_headers_description
48
+        assert description.input_forms == input_forms_description
49
+        assert description.output_headers == output_headers_description
50
+        assert description.output_body == output_body_description
51
+        assert description.errors == [error_description]
52
+
53
+        buffer.clear()
54
+        description = buffer.get_description()
55
+
56
+        assert description.input_path is None
57
+        assert description.input_query is None
58
+        assert description.input_body is None
59
+        assert description.input_headers is None
60
+        assert description.input_forms is None
61
+        assert description.output_headers is None
62
+        assert description.output_body is None
63
+        assert description.errors == []
64
+
65
+    def test_unit__buffer_usage__error__cant_replace(self):
66
+        buffer = DecorationBuffer()
67
+
68
+        input_path_description = InputPathDescription(fake_controller_wrapper)
69
+        input_query_description = InputQueryDescription(fake_controller_wrapper)  # nopep8
70
+        input_body_description = InputBodyDescription(fake_controller_wrapper)
71
+        input_headers_description = InputHeadersDescription(fake_controller_wrapper)  # nopep8
72
+        input_forms_description = InputFormsDescription(fake_controller_wrapper)  # nopep8
73
+        output_headers_description = OutputHeadersDescription(fake_controller_wrapper)  # nopep8
74
+        output_body_description = OutputBodyDescription(fake_controller_wrapper)  # nopep8
75
+
76
+        buffer.input_path = input_path_description
77
+        buffer.input_query = input_query_description
78
+        buffer.input_body = input_body_description
79
+        buffer.input_headers = input_headers_description
80
+        buffer.input_forms = input_forms_description
81
+        buffer.output_headers = output_headers_description
82
+        buffer.output_body = output_body_description
83
+
84
+        with pytest.raises(AlreadyDecoratedException):
85
+            buffer.input_path = input_path_description
86
+
87
+        with pytest.raises(AlreadyDecoratedException):
88
+            buffer.input_query = input_query_description
89
+
90
+        with pytest.raises(AlreadyDecoratedException):
91
+            buffer.input_body = input_body_description
92
+
93
+        with pytest.raises(AlreadyDecoratedException):
94
+            buffer.input_headers = input_headers_description
95
+
96
+        with pytest.raises(AlreadyDecoratedException):
97
+            buffer.input_forms = input_forms_description
98
+
99
+        with pytest.raises(AlreadyDecoratedException):
100
+            buffer.output_headers = output_headers_description
101
+
102
+        with pytest.raises(AlreadyDecoratedException):
103
+            buffer.output_body = output_body_description

+ 173 - 0
tests/unit/test_decorator.py View File

@@ -0,0 +1,173 @@
1
+# -*- coding: utf-8 -*-
2
+import typing
3
+from http import HTTPStatus
4
+
5
+from hapic.context import ContextInterface
6
+from hapic.data import HapicData
7
+from hapic.decorator import ControllerWrapper
8
+from hapic.decorator import InputControllerWrapper
9
+from hapic.decorator import OutputControllerWrapper
10
+from hapic.processor import RequestParameters
11
+from hapic.processor import ProcessValidationError
12
+from hapic.processor import ProcessorInterface
13
+from tests.base import Base
14
+
15
+
16
+class MyContext(ContextInterface):
17
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
18
+        return RequestParameters(
19
+            path_parameters={'fake': args},
20
+            query_parameters={},
21
+            body_parameters={},
22
+            form_parameters={},
23
+            header_parameters={},
24
+        )
25
+
26
+    def get_response(
27
+        self,
28
+        response: dict,
29
+        http_code: int,
30
+    ) -> typing.Any:
31
+        return {
32
+            'original_response': response,
33
+            'http_code': http_code,
34
+        }
35
+
36
+    def get_validation_error_response(
37
+        self,
38
+        error: ProcessValidationError,
39
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
40
+    ) -> typing.Any:
41
+        return {
42
+            'original_error': error,
43
+            'http_code': http_code,
44
+        }
45
+
46
+
47
+class MyProcessor(ProcessorInterface):
48
+    def process(self, value):
49
+        return value + 1
50
+
51
+    def get_validation_error(
52
+        self,
53
+        request_context: RequestParameters,
54
+    ) -> ProcessValidationError:
55
+        return ProcessValidationError(
56
+            error_details={
57
+                'original_request_context': request_context,
58
+            },
59
+            error_message='ERROR',
60
+        )
61
+
62
+
63
+class MyControllerWrapper(ControllerWrapper):
64
+    def before_wrapped_func(
65
+        self,
66
+        func_args: typing.Tuple[typing.Any, ...],
67
+        func_kwargs: typing.Dict[str, typing.Any],
68
+    ) -> typing.Union[None, typing.Any]:
69
+        if func_args and func_args[0] == 666:
70
+            return {
71
+                'error_response': 'we are testing'
72
+            }
73
+
74
+        func_kwargs['added_parameter'] = 'a value'
75
+
76
+    def after_wrapped_function(self, response: typing.Any) -> typing.Any:
77
+        return response * 2
78
+
79
+
80
+class MyInputControllerWrapper(InputControllerWrapper):
81
+    def get_processed_data(
82
+        self,
83
+        request_parameters: RequestParameters,
84
+    ) -> typing.Any:
85
+        return {'we_are_testing': request_parameters.path_parameters}
86
+
87
+    def update_hapic_data(
88
+        self,
89
+        hapic_data: HapicData,
90
+        processed_data: typing.Dict[str, typing.Any],
91
+    ) -> typing.Any:
92
+        hapic_data.query = processed_data
93
+
94
+
95
+class TestControllerWrapper(Base):
96
+    def test_unit__base_controller_wrapper__ok__no_behaviour(self):
97
+        context = MyContext()
98
+        processor = MyProcessor()
99
+        wrapper = ControllerWrapper(context, processor)
100
+
101
+        @wrapper.get_wrapper
102
+        def func(foo):
103
+            return foo
104
+
105
+        result = func(42)
106
+        assert result == 42
107
+
108
+    def test_unit__base_controller__ok__replaced_response(self):
109
+        context = MyContext()
110
+        processor = MyProcessor()
111
+        wrapper = MyControllerWrapper(context, processor)
112
+
113
+        @wrapper.get_wrapper
114
+        def func(foo):
115
+            return foo
116
+
117
+        # see MyControllerWrapper#before_wrapped_func
118
+        result = func(666)
119
+        # result have been replaced by MyControllerWrapper#before_wrapped_func
120
+        assert {'error_response': 'we are testing'} == result
121
+
122
+    def test_unit__controller_wrapper__ok__overload_input(self):
123
+        context = MyContext()
124
+        processor = MyProcessor()
125
+        wrapper = MyControllerWrapper(context, processor)
126
+
127
+        @wrapper.get_wrapper
128
+        def func(foo, added_parameter=None):
129
+            # see MyControllerWrapper#before_wrapped_func
130
+            assert added_parameter == 'a value'
131
+            return foo
132
+
133
+        result = func(42)
134
+        # See MyControllerWrapper#after_wrapped_function
135
+        assert result == 84
136
+
137
+
138
+class TestInputControllerWrapper(Base):
139
+    def test_unit__input_data_wrapping__ok__nominal_case(self):
140
+        context = MyContext()
141
+        processor = MyProcessor()
142
+        wrapper = MyInputControllerWrapper(context, processor)
143
+
144
+        @wrapper.get_wrapper
145
+        def func(foo, hapic_data=None):
146
+            assert hapic_data
147
+            assert isinstance(hapic_data, HapicData)
148
+            # see MyControllerWrapper#before_wrapped_func
149
+            assert hapic_data.query == {'we_are_testing': {'fake': (42,)}}
150
+            return foo
151
+
152
+        result = func(42)
153
+        assert result == 42
154
+
155
+
156
+class TestOutputControllerWrapper(Base):
157
+    def test_unit__output_data_wrapping__ok__nominal_case(self):
158
+        context = MyContext()
159
+        processor = MyProcessor()
160
+        wrapper = OutputControllerWrapper(context, processor)
161
+
162
+        @wrapper.get_wrapper
163
+        def func(foo, hapic_data=None):
164
+            # If no use of input wrapper, no hapic_data is given
165
+            assert not hapic_data
166
+            return foo
167
+
168
+        result = func(42)
169
+        # see MyProcessor#process
170
+        assert {
171
+                   'http_code': HTTPStatus.OK,
172
+                   'original_response': 43,
173
+               } == result

+ 86 - 0
tests/unit/test_processor.py View File

@@ -0,0 +1,86 @@
1
+# -*- coding: utf-8 -*-
2
+import marshmallow as marshmallow
3
+import pytest
4
+
5
+from hapic.exception import OutputValidationException
6
+from hapic.processor import MarshmallowOutputProcessor
7
+from hapic.processor import MarshmallowInputProcessor
8
+from tests.base import Base
9
+
10
+
11
+class MySchema(marshmallow.Schema):
12
+    first_name = marshmallow.fields.String(required=True)
13
+    last_name = marshmallow.fields.String(missing='Doe')
14
+
15
+
16
+class TestProcessor(Base):
17
+    def test_unit__marshmallow_output_processor__ok__process_success(self):
18
+        processor = MarshmallowOutputProcessor()
19
+        processor.schema = MySchema()
20
+
21
+        tested_data = {
22
+            'first_name': 'Alan',
23
+            'last_name': 'Turing',
24
+        }
25
+        data = processor.process(tested_data)
26
+
27
+        assert data == tested_data
28
+
29
+    def test_unit__marshmallow_output_processor__ok__missing_data(self):
30
+        """
31
+        Important note: Actually marshmallow don't validate when deserialize.
32
+        But we think about make it possible:
33
+        https://github.com/marshmallow-code/marshmallow/issues/684
34
+        """
35
+        processor = MarshmallowOutputProcessor()
36
+        processor.schema = MySchema()
37
+
38
+        tested_data = {
39
+            'last_name': 'Turing',
40
+        }
41
+
42
+        data = processor.process(tested_data)
43
+        assert {
44
+            'last_name': 'Turing',
45
+        } == data
46
+
47
+    def test_unit__marshmallow_input_processor__ok__process_success(self):
48
+        processor = MarshmallowInputProcessor()
49
+        processor.schema = MySchema()
50
+
51
+        tested_data = {
52
+            'first_name': 'Alan',
53
+            'last_name': 'Turing',
54
+        }
55
+        data = processor.process(tested_data)
56
+
57
+        assert data == tested_data
58
+
59
+    def test_unit__marshmallow_input_processor__error__validation_error(self):
60
+        processor = MarshmallowInputProcessor()
61
+        processor.schema = MySchema()
62
+
63
+        tested_data = {
64
+            'last_name': 'Turing',
65
+        }
66
+
67
+        with pytest.raises(OutputValidationException):
68
+            processor.process(tested_data)
69
+
70
+        errors = processor.get_validation_error(tested_data)
71
+        assert errors.error_details
72
+        assert 'first_name' in errors.error_details
73
+
74
+    def test_unit__marshmallow_input_processor__ok__completed_data(self):
75
+        processor = MarshmallowInputProcessor()
76
+        processor.schema = MySchema()
77
+
78
+        tested_data = {
79
+            'first_name': 'Alan',
80
+        }
81
+
82
+        data = processor.process(tested_data)
83
+        assert {
84
+            'first_name': 'Alan',
85
+            'last_name': 'Doe',
86
+        } == data