Browse Source

finish basic support (without doc generation) of aiohttp and outuput stream in async

Bastien Sevajol 2 years ago
parent
commit
18afa078a9
6 changed files with 282 additions and 14 deletions
  1. 1 1
      hapic/async.py
  2. 18 6
      hapic/decorator.py
  3. 28 5
      hapic/ext/aiohttp/context.py
  4. 2 2
      hapic/hapic.py
  5. 2 0
      setup.py
  6. 231 0
      tests/ext/unit/test_aiohttp.py

+ 1 - 1
hapic/async.py View File

@@ -1,7 +1,7 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 from hapic.hapic import Hapic
3 3
 
4
-_hapic_default = Hapic(async=True)
4
+_hapic_default = Hapic(async_=True)
5 5
 
6 6
 with_api_doc = _hapic_default.with_api_doc
7 7
 input_headers = _hapic_default.input_headers

+ 18 - 6
hapic/decorator.py View File

@@ -78,7 +78,7 @@ class ControllerWrapper(object):
78 78
             # Note: Design of before_wrapped_func can be to update kwargs
79 79
             # by reference here
80 80
             replacement_response = self.before_wrapped_func(args, kwargs)
81
-            if replacement_response:
81
+            if replacement_response is not None:
82 82
                 return replacement_response
83 83
 
84 84
             response = self._execute_wrapped_function(func, args, kwargs)
@@ -203,7 +203,7 @@ class AsyncInputControllerWrapper(InputControllerWrapper):
203 203
             # Note: Design of before_wrapped_func can be to update kwargs
204 204
             # by reference here
205 205
             replacement_response = await self.before_wrapped_func(args, kwargs)
206
-            if replacement_response:
206
+            if replacement_response is not None:
207 207
                 return replacement_response
208 208
 
209 209
             response = await self._execute_wrapped_function(func, args, kwargs)
@@ -229,7 +229,7 @@ class AsyncInputControllerWrapper(InputControllerWrapper):
229 229
             processed_data = await self.get_processed_data(request_parameters)
230 230
             self.update_hapic_data(hapic_data, processed_data)
231 231
         except ProcessException:
232
-            error_response = self.get_error_response(request_parameters)
232
+            error_response = await self.get_error_response(request_parameters)
233 233
             return error_response
234 234
 
235 235
     async def get_processed_data(
@@ -327,7 +327,7 @@ class AsyncOutputBodyControllerWrapper(OutputControllerWrapper):
327 327
             # Note: Design of before_wrapped_func can be to update kwargs
328 328
             # by reference here
329 329
             replacement_response = self.before_wrapped_func(args, kwargs)
330
-            if replacement_response:
330
+            if replacement_response is not None:
331 331
                 return replacement_response
332 332
 
333 333
             response = await self._execute_wrapped_function(func, args, kwargs)
@@ -346,14 +346,14 @@ class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
346 346
             # Note: Design of before_wrapped_func can be to update kwargs
347 347
             # by reference here
348 348
             replacement_response = self.before_wrapped_func(args, kwargs)
349
-            if replacement_response:
349
+            if replacement_response is not None:
350 350
                 return replacement_response
351 351
 
352 352
             stream_response = await self.context.get_stream_response_object(
353 353
                 args,
354 354
                 kwargs,
355 355
             )
356
-            async for stream_item in self._execute_wrapped_function(
356
+            async for stream_item in await self._execute_wrapped_function(
357 357
                 func,
358 358
                 args,
359 359
                 kwargs,
@@ -475,6 +475,18 @@ class AsyncInputBodyControllerWrapper(AsyncInputControllerWrapper):
475 475
     async def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
476 476
         return await request_parameters.body_parameters
477 477
 
478
+    async def get_error_response(
479
+        self,
480
+        request_parameters: RequestParameters,
481
+    ) -> typing.Any:
482
+        parameters_data = await self.get_parameters_data(request_parameters)
483
+        error = self.processor.get_validation_error(parameters_data)
484
+        error_response = self.context.get_validation_error_response(
485
+            error,
486
+            http_code=self.error_http_code,
487
+        )
488
+        return error_response
489
+
478 490
 
479 491
 class InputHeadersControllerWrapper(InputControllerWrapper):
480 492
     def update_hapic_data(

+ 28 - 5
hapic/ext/aiohttp/context.py View File

@@ -12,7 +12,8 @@ from multidict import MultiDict
12 12
 from hapic.context import BaseContext
13 13
 from hapic.context import RouteRepresentation
14 14
 from hapic.decorator import DecoratedController
15
-from hapic.exception import WorkflowException
15
+from hapic.error import ErrorBuilderInterface, DefaultErrorBuilder
16
+from hapic.exception import WorkflowException, OutputValidationException
16 17
 from hapic.processor import ProcessValidationError
17 18
 from hapic.processor import RequestParameters
18 19
 from aiohttp import web
@@ -66,10 +67,13 @@ class AiohttpContext(BaseContext):
66 67
     def __init__(
67 68
         self,
68 69
         app: web.Application,
70
+        default_error_builder: ErrorBuilderInterface=None,
69 71
         debug: bool = False,
70 72
     ) -> None:
71 73
         self._app = app
72 74
         self._debug = debug
75
+        self.default_error_builder = \
76
+            default_error_builder or DefaultErrorBuilder()  # FDV
73 77
 
74 78
     @property
75 79
     def app(self) -> web.Application:
@@ -106,8 +110,28 @@ class AiohttpContext(BaseContext):
106 110
         error: ProcessValidationError,
107 111
         http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
108 112
     ) -> typing.Any:
109
-        # TODO BS 2018-07-24: To do
110
-        raise NotImplementedError('todo')
113
+        error_builder = self.get_default_error_builder()
114
+        error_content = error_builder.build_from_validation_error(
115
+            error,
116
+        )
117
+
118
+        # Check error
119
+        dumped = error_builder.dump(error_content).data
120
+        unmarshall = error_builder.load(dumped)
121
+        if unmarshall.errors:
122
+            raise OutputValidationException(
123
+                'Validation error during dump of error response: {}'.format(
124
+                    str(unmarshall.errors)
125
+                )
126
+            )
127
+
128
+        return web.Response(
129
+            text=json.dumps(dumped),
130
+            headers=[
131
+                ('Content-Type', 'application/json'),
132
+            ],
133
+            status=int(http_code),
134
+        )
111 135
 
112 136
     def find_route(
113 137
         self,
@@ -127,8 +151,7 @@ class AiohttpContext(BaseContext):
127 151
         self,
128 152
         response: typing.Any,
129 153
     ) -> bool:
130
-        # TODO BS 2018-07-15: to do
131
-        raise NotImplementedError('todo')
154
+        return isinstance(response, web.Response)
132 155
 
133 156
     def add_view(
134 157
         self,

+ 2 - 2
hapic/hapic.py View File

@@ -51,13 +51,13 @@ from hapic.error import ErrorBuilderInterface
51 51
 class Hapic(object):
52 52
     def __init__(
53 53
         self,
54
-        async: bool = False,
54
+        async_: bool = False,
55 55
     ):
56 56
         self._buffer = DecorationBuffer()
57 57
         self._controllers = []  # type: typing.List[DecoratedController]
58 58
         self._context = None  # type: ContextInterface
59 59
         self._error_builder = None  # type: ErrorBuilderInterface
60
-        self._async = async
60
+        self._async = async_
61 61
         self.doc_generator = DocGenerator()
62 62
 
63 63
         # This local function will be pass to different components

+ 2 - 0
setup.py View File

@@ -29,6 +29,8 @@ tests_require = [
29 29
     'flask',
30 30
     'pyramid',
31 31
     'webtest',
32
+    'aiohttp',
33
+    'pytest-aiohttp',
32 34
 ]
33 35
 dev_require = [
34 36
     'requests',

+ 231 - 0
tests/ext/unit/test_aiohttp.py View File

@@ -0,0 +1,231 @@
1
+# coding: utf-8
2
+from aiohttp import web
3
+import marshmallow
4
+
5
+from hapic import Hapic
6
+from hapic import HapicData
7
+from hapic.ext.aiohttp.context import AiohttpContext
8
+
9
+
10
+class TestAiohttpExt(object):
11
+    async def test_aiohttp_only__ok__nominal_case(
12
+        self,
13
+        aiohttp_client,
14
+        loop,
15
+    ):
16
+        async def hello(request):
17
+            return web.Response(text='Hello, world')
18
+
19
+        app = web.Application(debug=True)
20
+        app.router.add_get('/', hello)
21
+        client = await aiohttp_client(app)
22
+        resp = await client.get('/')
23
+        assert resp.status == 200
24
+        text = await resp.text()
25
+        assert 'Hello, world' in text
26
+
27
+    async def test_aiohttp_input_path__ok__nominal_case(
28
+        self,
29
+        aiohttp_client,
30
+        loop,
31
+    ):
32
+        hapic = Hapic(async_=True)
33
+
34
+        class InputPathSchema(marshmallow.Schema):
35
+            name = marshmallow.fields.String()
36
+
37
+        @hapic.input_path(InputPathSchema())
38
+        async def hello(request, hapic_data: HapicData):
39
+            name = hapic_data.path.get('name')
40
+            return web.Response(text='Hello, {}'.format(name))
41
+
42
+        app = web.Application(debug=True)
43
+        app.router.add_get('/{name}', hello)
44
+        hapic.set_context(AiohttpContext(app))
45
+        client = await aiohttp_client(app)
46
+
47
+        resp = await client.get('/bob')
48
+        assert resp.status == 200
49
+
50
+        text = await resp.text()
51
+        assert 'Hello, bob' in text
52
+
53
+    async def test_aiohttp_input_path__error_wrong_input_parameter(
54
+        self,
55
+        aiohttp_client,
56
+        loop,
57
+    ):
58
+        hapic = Hapic(async_=True)
59
+
60
+        class InputPathSchema(marshmallow.Schema):
61
+            i = marshmallow.fields.Integer()
62
+
63
+        @hapic.input_path(InputPathSchema())
64
+        async def hello(request, hapic_data: HapicData):
65
+            i = hapic_data.path.get('i')
66
+            return web.Response(text='integer: {}'.format(str(i)))
67
+
68
+        app = web.Application(debug=True)
69
+        app.router.add_get('/{i}', hello)
70
+        hapic.set_context(AiohttpContext(app))
71
+        client = await aiohttp_client(app)
72
+
73
+        resp = await client.get('/bob')  # NOTE: should be integer here
74
+        assert resp.status == 400
75
+
76
+        error = await resp.json()
77
+        assert 'Validation error of input data' in error.get('message')
78
+        assert {'i': ['Not a valid integer.']} == error.get('details')
79
+
80
+    async def test_aiohttp_input_body__ok_nominal_case(
81
+        self,
82
+        aiohttp_client,
83
+        loop,
84
+    ):
85
+        hapic = Hapic(async_=True)
86
+
87
+        class InputBodySchema(marshmallow.Schema):
88
+            name = marshmallow.fields.String()
89
+
90
+        @hapic.input_body(InputBodySchema())
91
+        async def hello(request, hapic_data: HapicData):
92
+            name = hapic_data.body.get('name')
93
+            return web.Response(text='Hello, {}'.format(name))
94
+
95
+        app = web.Application(debug=True)
96
+        app.router.add_post('/', hello)
97
+        hapic.set_context(AiohttpContext(app))
98
+        client = await aiohttp_client(app)
99
+
100
+        resp = await client.post('/', data={'name': 'bob'})
101
+        assert resp.status == 200
102
+
103
+        text = await resp.text()
104
+        assert 'Hello, bob' in text
105
+
106
+    async def test_aiohttp_input_body__error__incorrect_input_body(
107
+        self,
108
+        aiohttp_client,
109
+        loop,
110
+    ):
111
+        hapic = Hapic(async_=True)
112
+
113
+        class InputBodySchema(marshmallow.Schema):
114
+            i = marshmallow.fields.Integer()
115
+
116
+        @hapic.input_body(InputBodySchema())
117
+        async def hello(request, hapic_data: HapicData):
118
+            i = hapic_data.body.get('i')
119
+            return web.Response(text='integer, {}'.format(i))
120
+
121
+        app = web.Application(debug=True)
122
+        app.router.add_post('/', hello)
123
+        hapic.set_context(AiohttpContext(app))
124
+        client = await aiohttp_client(app)
125
+
126
+        resp = await client.post('/', data={'i': 'bob'})  # NOTE: should be int
127
+        assert resp.status == 400
128
+
129
+        error = await resp.json()
130
+        assert 'Validation error of input data' in error.get('message')
131
+        assert {'i': ['Not a valid integer.']} == error.get('details')
132
+
133
+    async def test_aiohttp_output_body__ok__nominal_case(
134
+        self,
135
+        aiohttp_client,
136
+        loop,
137
+    ):
138
+        hapic = Hapic(async_=True)
139
+
140
+        class OuputBodySchema(marshmallow.Schema):
141
+            name = marshmallow.fields.String()
142
+
143
+        @hapic.output_body(OuputBodySchema())
144
+        async def hello(request):
145
+            return {
146
+                'name': 'bob',
147
+            }
148
+
149
+        app = web.Application(debug=True)
150
+        app.router.add_get('/', hello)
151
+        hapic.set_context(AiohttpContext(app))
152
+        client = await aiohttp_client(app)
153
+
154
+        resp = await client.get('/')
155
+        assert resp.status == 200
156
+
157
+        data = await resp.json()
158
+        assert 'bob' == data.get('name')
159
+
160
+    async def test_aiohttp_output_body__error__incorrect_output_body(
161
+        self,
162
+        aiohttp_client,
163
+        loop,
164
+    ):
165
+        hapic = Hapic(async_=True)
166
+
167
+        class OuputBodySchema(marshmallow.Schema):
168
+            i = marshmallow.fields.Integer(required=True)
169
+
170
+        @hapic.output_body(OuputBodySchema())
171
+        async def hello(request):
172
+            return {
173
+                'i': 'bob',  # NOTE: should be integer
174
+            }
175
+
176
+        app = web.Application(debug=True)
177
+        app.router.add_get('/', hello)
178
+        hapic.set_context(AiohttpContext(app))
179
+        client = await aiohttp_client(app)
180
+
181
+        resp = await client.get('/')
182
+        assert resp.status == 500
183
+
184
+        data = await resp.json()
185
+        assert 'Validation error of output data' == data.get('message')
186
+        assert {
187
+                   'i': ['Missing data for required field.'],
188
+               } == data.get('details')
189
+
190
+    async def test_aiohttp_output_stream__ok__nominal_case(
191
+        self,
192
+        aiohttp_client,
193
+        loop,
194
+    ):
195
+        hapic = Hapic(async_=True)
196
+
197
+        class AsyncGenerator:
198
+            def __init__(self):
199
+                self._iterator = iter([
200
+                    {'name': 'Hello, bob'},
201
+                    {'name': 'Hello, franck'},
202
+                ])
203
+
204
+            async def __aiter__(self):
205
+                return self
206
+
207
+            async def __anext__(self):
208
+                return next(self._iterator)
209
+
210
+        class OuputStreamItemSchema(marshmallow.Schema):
211
+            name = marshmallow.fields.String()
212
+
213
+        @hapic.output_stream(OuputStreamItemSchema())
214
+        async def hello(request):
215
+            return AsyncGenerator()
216
+
217
+        app = web.Application(debug=True)
218
+        app.router.add_get('/', hello)
219
+        hapic.set_context(AiohttpContext(app))
220
+        client = await aiohttp_client(app)
221
+
222
+        resp = await client.get('/')
223
+        assert resp.status == 200
224
+
225
+        line = await resp.content.readline()
226
+        assert b'{"name": "Hello, bob"}\n' == line
227
+
228
+        line = await resp.content.readline()
229
+        assert b'{"name": "Hello, franck"}\n' == line
230
+
231
+        # TODO BS 2018-07-26: How to ensure we are at end of response ?