Browse Source

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

Bastien Sevajol 2 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,7 +1,4 @@
1 1
 # coding: utf-8
2
-import json
3
-import yaml
4
-
5 2
 from aiohttp import web
6 3
 from hapic import async as hapic
7 4
 from hapic import async as HapicData
@@ -10,35 +7,28 @@ import marshmallow
10 7
 from hapic.ext.aiohttp.context import AiohttpContext
11 8
 
12 9
 
13
-class HandleInputPath(marshmallow.Schema):
10
+class DisplayNameInputPathSchema(marshmallow.Schema):
14 11
     name = marshmallow.fields.String(
15 12
         required=False,
16 13
         allow_none=True,
17 14
     )
18 15
 
19 16
 
20
-class HandleInputBody(marshmallow.Schema):
17
+class DisplayBodyInputBodySchema(marshmallow.Schema):
21 18
     foo = marshmallow.fields.String(
22 19
         required=True,
23 20
     )
24 21
 
25 22
 
26
-class Handle2OutputBody(marshmallow.Schema):
23
+class DisplayBodyOutputBodySchema(marshmallow.Schema):
27 24
     data = marshmallow.fields.Dict(
28 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 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 32
     name = request.match_info.get('name', "Anonymous")
43 33
     text = "Hello, " + name
44 34
     return web.json_response({
@@ -47,9 +37,9 @@ async def handle(request, hapic_data):
47 37
 
48 38
 
49 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 43
     data = hapic_data.body
54 44
     return {
55 45
         'data': data,
@@ -67,16 +57,19 @@ async def do_login(request):
67 57
 
68 58
 app = web.Application(debug=True)
69 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 64
     web.post('/login', do_login),
75 65
 ])
76 66
 
77 67
 
78 68
 hapic.set_context(AiohttpContext(app))
79 69
 
70
+
71
+# import json
72
+# import yaml
80 73
 # print(yaml.dump(
81 74
 #     json.loads(json.dumps(hapic.generate_doc())),
82 75
 #     default_flow_style=False,

+ 11 - 0
hapic/buffer.py View File

@@ -9,6 +9,7 @@ from hapic.description import InputHeadersDescription
9 9
 from hapic.description import InputFormsDescription
10 10
 from hapic.description import InputFilesDescription
11 11
 from hapic.description import OutputBodyDescription
12
+from hapic.description import OutputStreamDescription
12 13
 from hapic.description import OutputFileDescription
13 14
 from hapic.description import OutputHeadersDescription
14 15
 from hapic.description import ErrorDescription
@@ -96,6 +97,16 @@ class DecorationBuffer(object):
96 97
         self._description.output_body = description
97 98
 
98 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 110
     def output_file(self) -> OutputFileDescription:
100 111
         return self._description.output_file
101 112
 

+ 32 - 11
hapic/decorator.py View File

@@ -337,6 +337,26 @@ class AsyncOutputBodyControllerWrapper(OutputControllerWrapper):
337 337
 
338 338
 
339 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 360
     def get_wrapper(
341 361
         self,
342 362
         func: 'typing.Callable[..., typing.Any]',
@@ -358,11 +378,17 @@ class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
358 378
                 args,
359 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 393
             return stream_response
368 394
 
@@ -372,12 +398,7 @@ class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
372 398
         self,
373 399
         item_object: typing.Any,
374 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 404
 class OutputHeadersControllerWrapper(OutputControllerWrapper):

+ 17 - 0
hapic/doc.py View File

@@ -158,6 +158,23 @@ def bottle_generate_operations(
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 178
     if description.output_file:
162 179
         method_operations.setdefault('produces', []).extend(
163 180
             description.output_file.wrapper.output_types

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

@@ -23,7 +23,7 @@ from hapic.processor import RequestParameters
23 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 27
 AIOHTTP_RE_PATH_URL = re.compile(r'{([^:<>]+)(?::[^<>]+)?}')
28 28
 
29 29
 

+ 16 - 0
hapic/hapic.py View File

@@ -180,7 +180,22 @@ class Hapic(object):
180 180
         context: ContextInterface = None,
181 181
         error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
182 182
         default_http_code: HTTPStatus = HTTPStatus.OK,
183
+        ignore_on_error: bool = True,
183 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 199
         processor = processor or MarshmallowOutputProcessor()
185 200
         processor.schema = item_schema
186 201
         context = context or self._context_getter
@@ -191,6 +206,7 @@ class Hapic(object):
191 206
                 processor=processor,
192 207
                 error_http_code=error_http_code,
193 208
                 default_http_code=default_http_code,
209
+                ignore_on_error=ignore_on_error,
194 210
             )
195 211
         else:
196 212
             # TODO BS 2018-07-25: To do

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

@@ -250,7 +250,89 @@ class TestAiohttpExt(object):
250 250
         line = await resp.content.readline()
251 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 337
     def test_unit__generate_doc__ok__nominal_case(
256 338
         self,
@@ -334,3 +416,33 @@ class TestAiohttpExt(object):
334 416
                        'description': '200',
335 417
                    }
336 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']