Browse Source

Merge branch 'feature/76__aiohttp' of github.com:algoo/hapic into feature/76__aiohttp

Bastien Sevajol 5 years ago
parent
commit
ce94d6c174
7 changed files with 205 additions and 35 deletions
  1. 15 22
      example/example_a_aiohttp.py
  2. 11 0
      hapic/buffer.py
  3. 32 11
      hapic/decorator.py
  4. 17 0
      hapic/doc.py
  5. 1 1
      hapic/ext/aiohttp/context.py
  6. 16 0
      hapic/hapic.py
  7. 113 1
      tests/ext/unit/test_aiohttp.py

+ 15 - 22
example/example_a_aiohttp.py View File

1
 # coding: utf-8
1
 # coding: utf-8
2
-import json
3
-import yaml
4
-
5
 from aiohttp import web
2
 from aiohttp import web
6
 from hapic import async as hapic
3
 from hapic import async as hapic
7
 from hapic import async as HapicData
4
 from hapic import async as HapicData
10
 from hapic.ext.aiohttp.context import AiohttpContext
7
 from hapic.ext.aiohttp.context import AiohttpContext
11
 
8
 
12
 
9
 
13
-class HandleInputPath(marshmallow.Schema):
10
+class DisplayNameInputPathSchema(marshmallow.Schema):
14
     name = marshmallow.fields.String(
11
     name = marshmallow.fields.String(
15
         required=False,
12
         required=False,
16
         allow_none=True,
13
         allow_none=True,
17
     )
14
     )
18
 
15
 
19
 
16
 
20
-class HandleInputBody(marshmallow.Schema):
17
+class DisplayBodyInputBodySchema(marshmallow.Schema):
21
     foo = marshmallow.fields.String(
18
     foo = marshmallow.fields.String(
22
         required=True,
19
         required=True,
23
     )
20
     )
24
 
21
 
25
 
22
 
26
-class Handle2OutputBody(marshmallow.Schema):
23
+class DisplayBodyOutputBodySchema(marshmallow.Schema):
27
     data = marshmallow.fields.Dict(
24
     data = marshmallow.fields.Dict(
28
         required=True,
25
         required=True,
29
     )
26
     )
30
 
27
 
31
 
28
 
32
-class HandleOutputBody(marshmallow.Schema):
33
-    sentence = marshmallow.fields.String(
34
-        required=True,
35
-    )
36
-
37
-
38
 @hapic.with_api_doc()
29
 @hapic.with_api_doc()
39
-@hapic.input_path(HandleInputPath())
40
-# @hapic.output_body(HandleOutputBody())
41
-async def handle(request, hapic_data):
30
+@hapic.input_path(DisplayNameInputPathSchema())
31
+async def display_name(request, hapic_data):
42
     name = request.match_info.get('name', "Anonymous")
32
     name = request.match_info.get('name', "Anonymous")
43
     text = "Hello, " + name
33
     text = "Hello, " + name
44
     return web.json_response({
34
     return web.json_response({
47
 
37
 
48
 
38
 
49
 @hapic.with_api_doc()
39
 @hapic.with_api_doc()
50
-@hapic.input_body(HandleInputBody())
51
-@hapic.output_body(Handle2OutputBody())
52
-async def handle2(request, hapic_data: HapicData):
40
+@hapic.input_body(DisplayBodyInputBodySchema())
41
+@hapic.output_body(DisplayBodyOutputBodySchema())
42
+async def display_body(request, hapic_data: HapicData):
53
     data = hapic_data.body
43
     data = hapic_data.body
54
     return {
44
     return {
55
         'data': data,
45
         'data': data,
67
 
57
 
68
 app = web.Application(debug=True)
58
 app = web.Application(debug=True)
69
 app.add_routes([
59
 app.add_routes([
70
-    web.get('/n/', handle),
71
-    web.get('/n/{name}', handle),
72
-    web.post('/n/{name}', handle),
73
-    web.post('/b/', handle2),
60
+    web.get('/n/', display_name),
61
+    web.get('/n/{name}', display_name),
62
+    web.post('/n/{name}', display_name),
63
+    web.post('/b/', display_body),
74
     web.post('/login', do_login),
64
     web.post('/login', do_login),
75
 ])
65
 ])
76
 
66
 
77
 
67
 
78
 hapic.set_context(AiohttpContext(app))
68
 hapic.set_context(AiohttpContext(app))
79
 
69
 
70
+
71
+# import json
72
+# import yaml
80
 # print(yaml.dump(
73
 # print(yaml.dump(
81
 #     json.loads(json.dumps(hapic.generate_doc())),
74
 #     json.loads(json.dumps(hapic.generate_doc())),
82
 #     default_flow_style=False,
75
 #     default_flow_style=False,

+ 11 - 0
hapic/buffer.py View File

9
 from hapic.description import InputFormsDescription
9
 from hapic.description import InputFormsDescription
10
 from hapic.description import InputFilesDescription
10
 from hapic.description import InputFilesDescription
11
 from hapic.description import OutputBodyDescription
11
 from hapic.description import OutputBodyDescription
12
+from hapic.description import OutputStreamDescription
12
 from hapic.description import OutputFileDescription
13
 from hapic.description import OutputFileDescription
13
 from hapic.description import OutputHeadersDescription
14
 from hapic.description import OutputHeadersDescription
14
 from hapic.description import ErrorDescription
15
 from hapic.description import ErrorDescription
96
         self._description.output_body = description
97
         self._description.output_body = description
97
 
98
 
98
     @property
99
     @property
100
+    def output_stream(self) -> OutputStreamDescription:
101
+        return self._description.output_stream
102
+
103
+    @output_stream.setter
104
+    def output_stream(self, description: OutputStreamDescription) -> None:
105
+        if self._description.output_stream is not None:
106
+            raise AlreadyDecoratedException()
107
+        self._description.output_stream = description
108
+
109
+    @property
99
     def output_file(self) -> OutputFileDescription:
110
     def output_file(self) -> OutputFileDescription:
100
         return self._description.output_file
111
         return self._description.output_file
101
 
112
 

+ 32 - 11
hapic/decorator.py View File

337
 
337
 
338
 
338
 
339
 class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
339
 class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
340
+    """
341
+    This controller wrapper produce a wrapper who caught the http view items
342
+    to check and serialize them into a stream response.
343
+    """
344
+    def __init__(
345
+        self,
346
+        context: typing.Union[ContextInterface, typing.Callable[[], ContextInterface]],  # nopep8
347
+        processor: ProcessorInterface,
348
+        error_http_code: HTTPStatus=HTTPStatus.INTERNAL_SERVER_ERROR,
349
+        default_http_code: HTTPStatus=HTTPStatus.OK,
350
+        ignore_on_error: bool = True,
351
+    ) -> None:
352
+        super().__init__(
353
+            context,
354
+            processor,
355
+            error_http_code,
356
+            default_http_code,
357
+        )
358
+        self.ignore_on_error = ignore_on_error
359
+
340
     def get_wrapper(
360
     def get_wrapper(
341
         self,
361
         self,
342
         func: 'typing.Callable[..., typing.Any]',
362
         func: 'typing.Callable[..., typing.Any]',
358
                 args,
378
                 args,
359
                 kwargs,
379
                 kwargs,
360
             ):
380
             ):
361
-                serialized_item = self._get_serialized_item(stream_item)
362
-                await self.context.feed_stream_response(
363
-                    stream_response,
364
-                    serialized_item,
365
-                )
381
+                try:
382
+                    serialized_item = self._get_serialized_item(stream_item)
383
+                    await self.context.feed_stream_response(
384
+                        stream_response,
385
+                        serialized_item,
386
+                    )
387
+                except OutputValidationException as exc:
388
+                    if not self.ignore_on_error:
389
+                        # TODO BS 2018-07-31: Something should inform about
390
+                        # error, a log ?
391
+                        return stream_response
366
 
392
 
367
             return stream_response
393
             return stream_response
368
 
394
 
372
         self,
398
         self,
373
         item_object: typing.Any,
399
         item_object: typing.Any,
374
     ) -> dict:
400
     ) -> dict:
375
-        try:
376
-            return self.processor.process(item_object)
377
-        except ProcessException:
378
-            # TODO BS 2018-07-25: Must interrupt stream response: but how
379
-            # inform about error ?
380
-            raise NotImplementedError('todo')
401
+        return self.processor.process(item_object)
381
 
402
 
382
 
403
 
383
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
404
 class OutputHeadersControllerWrapper(OutputControllerWrapper):

+ 17 - 0
hapic/doc.py View File

158
                 )
158
                 )
159
             }
159
             }
160
 
160
 
161
+    if description.output_stream:
162
+        # TODO BS 2018-07-31: Is that a correct way to re
163
+        # instanciate with .__class__ ... ?
164
+        method_operations.setdefault('responses', {})\
165
+            [int(description.output_stream.wrapper.default_http_code)] = {
166
+                'description': str(int(description.output_stream.wrapper.default_http_code)),  # nopep8
167
+                'schema': generate_schema_ref(
168
+                    spec,
169
+                    description
170
+                        .output_stream
171
+                        .wrapper
172
+                        .processor
173
+                        .schema
174
+                        .__class__(many=True),
175
+                )
176
+            }
177
+
161
     if description.output_file:
178
     if description.output_file:
162
         method_operations.setdefault('produces', []).extend(
179
         method_operations.setdefault('produces', []).extend(
163
             description.output_file.wrapper.output_types
180
             description.output_file.wrapper.output_types

+ 1 - 1
hapic/ext/aiohttp/context.py View File

23
 from aiohttp import web
23
 from aiohttp import web
24
 
24
 
25
 
25
 
26
-# Bottle regular expression to locate url parameters
26
+# Aiohttp regular expression to locate url parameters
27
 AIOHTTP_RE_PATH_URL = re.compile(r'{([^:<>]+)(?::[^<>]+)?}')
27
 AIOHTTP_RE_PATH_URL = re.compile(r'{([^:<>]+)(?::[^<>]+)?}')
28
 
28
 
29
 
29
 

+ 16 - 0
hapic/hapic.py View File

180
         context: ContextInterface = None,
180
         context: ContextInterface = None,
181
         error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
181
         error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
182
         default_http_code: HTTPStatus = HTTPStatus.OK,
182
         default_http_code: HTTPStatus = HTTPStatus.OK,
183
+        ignore_on_error: bool = True,
183
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
184
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
185
+        """
186
+        Decorate with a wrapper who check and serialize each items in output
187
+        stream.
188
+
189
+        :param item_schema: Schema of output stream items
190
+        :param processor: ProcessorInterface object to process with given
191
+        schema
192
+        :param context: Context to use here
193
+        :param error_http_code: http code in case of error
194
+        :param default_http_code: http code in case of success
195
+        :param ignore_on_error: if set, an error of serialization will be
196
+        ignored: stream will not send this failed object
197
+        :return: decorator
198
+        """
184
         processor = processor or MarshmallowOutputProcessor()
199
         processor = processor or MarshmallowOutputProcessor()
185
         processor.schema = item_schema
200
         processor.schema = item_schema
186
         context = context or self._context_getter
201
         context = context or self._context_getter
191
                 processor=processor,
206
                 processor=processor,
192
                 error_http_code=error_http_code,
207
                 error_http_code=error_http_code,
193
                 default_http_code=default_http_code,
208
                 default_http_code=default_http_code,
209
+                ignore_on_error=ignore_on_error,
194
             )
210
             )
195
         else:
211
         else:
196
             # TODO BS 2018-07-25: To do
212
             # TODO BS 2018-07-25: To do

+ 113 - 1
tests/ext/unit/test_aiohttp.py View File

250
         line = await resp.content.readline()
250
         line = await resp.content.readline()
251
         assert b'{"name": "Hello, franck"}\n' == line
251
         assert b'{"name": "Hello, franck"}\n' == line
252
 
252
 
253
-        # TODO BS 2018-07-26: How to ensure we are at end of response ?
253
+    async def test_aiohttp_output_stream__error__ignore(
254
+        self,
255
+        aiohttp_client,
256
+        loop,
257
+    ):
258
+        hapic = Hapic(async_=True)
259
+
260
+        class AsyncGenerator:
261
+            def __init__(self):
262
+                self._iterator = iter([
263
+                    {'name': 'Hello, bob'},
264
+                    {'nameZ': 'Hello, Z'},  # This line is incorrect
265
+                    {'name': 'Hello, franck'},
266
+                ])
267
+
268
+            async def __aiter__(self):
269
+                return self
270
+
271
+            async def __anext__(self):
272
+                return next(self._iterator)
273
+
274
+        class OuputStreamItemSchema(marshmallow.Schema):
275
+            name = marshmallow.fields.String(required=True)
276
+
277
+        @hapic.output_stream(OuputStreamItemSchema(), ignore_on_error=True)
278
+        async def hello(request):
279
+            return AsyncGenerator()
280
+
281
+        app = web.Application(debug=True)
282
+        app.router.add_get('/', hello)
283
+        hapic.set_context(AiohttpContext(app))
284
+        client = await aiohttp_client(app)
285
+
286
+        resp = await client.get('/')
287
+        assert resp.status == 200
288
+
289
+        line = await resp.content.readline()
290
+        assert b'{"name": "Hello, bob"}\n' == line
291
+
292
+        line = await resp.content.readline()
293
+        assert b'{"name": "Hello, franck"}\n' == line
294
+
295
+    async def test_aiohttp_output_stream__error__interrupt(
296
+        self,
297
+        aiohttp_client,
298
+        loop,
299
+    ):
300
+        hapic = Hapic(async_=True)
301
+
302
+        class AsyncGenerator:
303
+            def __init__(self):
304
+                self._iterator = iter([
305
+                    {'name': 'Hello, bob'},
306
+                    {'nameZ': 'Hello, Z'},  # This line is incorrect
307
+                    {'name': 'Hello, franck'},  # This line must not be reached
308
+                ])
309
+
310
+            async def __aiter__(self):
311
+                return self
312
+
313
+            async def __anext__(self):
314
+                return next(self._iterator)
315
+
316
+        class OuputStreamItemSchema(marshmallow.Schema):
317
+            name = marshmallow.fields.String(required=True)
318
+
319
+        @hapic.output_stream(OuputStreamItemSchema(), ignore_on_error=False)
320
+        async def hello(request):
321
+            return AsyncGenerator()
322
+
323
+        app = web.Application(debug=True)
324
+        app.router.add_get('/', hello)
325
+        hapic.set_context(AiohttpContext(app))
326
+        client = await aiohttp_client(app)
327
+
328
+        resp = await client.get('/')
329
+        assert resp.status == 200
330
+
331
+        line = await resp.content.readline()
332
+        assert b'{"name": "Hello, bob"}\n' == line
333
+
334
+        line = await resp.content.readline()
335
+        assert b'' == line
254
 
336
 
255
     def test_unit__generate_doc__ok__nominal_case(
337
     def test_unit__generate_doc__ok__nominal_case(
256
         self,
338
         self,
334
                        'description': '200',
416
                        'description': '200',
335
                    }
417
                    }
336
                } == doc['paths']['/{username}']['get']['responses']
418
                } == doc['paths']['/{username}']['get']['responses']
419
+
420
+    def test_unit__generate_output_stream_doc__ok__nominal_case(
421
+        self,
422
+        aiohttp_client,
423
+        loop,
424
+    ):
425
+        hapic = Hapic(async_=True)
426
+
427
+        class OuputStreamItemSchema(marshmallow.Schema):
428
+            name = marshmallow.fields.String(required=True)
429
+
430
+        @hapic.with_api_doc()
431
+        @hapic.output_stream(OuputStreamItemSchema())
432
+        async def get_users(request, hapic_data):
433
+            pass
434
+
435
+        app = web.Application(debug=True)
436
+        app.router.add_get('/', get_users)
437
+        hapic.set_context(AiohttpContext(app))
438
+
439
+        doc = hapic.generate_doc('aiohttp', 'testing')
440
+        assert '/' in doc.get('paths')
441
+        assert 'get' in doc['paths']['/']
442
+        assert 200 in doc['paths']['/']['get'].get('responses', {})
443
+        assert {
444
+            'items': {
445
+                '$ref': '#/definitions/OuputStreamItemSchema'
446
+            },
447
+            'type': 'array',
448
+        } == doc['paths']['/']['get']['responses'][200]['schema']