Browse Source

Merge pull request #79 from algoo/feature/76__aiohttp

Bastien Sevajol 5 years ago
parent
commit
749942fe07
No account linked to committer's email

+ 0 - 1
.travis.yml View File

1
 sudo: false
1
 sudo: false
2
 language: python
2
 language: python
3
 python:
3
 python:
4
-  - "3.4"
5
   - "3.5"
4
   - "3.5"
6
   - "3.6"
5
   - "3.6"
7
 
6
 

+ 2 - 0
README.md View File

34
 
34
 
35
 Hapic is under active development, based on Algoo projects. We will answer your questions and accept merge requests if you find bugs or want to include functionnalities.
35
 Hapic is under active development, based on Algoo projects. We will answer your questions and accept merge requests if you find bugs or want to include functionnalities.
36
 
36
 
37
+Hapic is currently tested on python 3.5 and 3.6.
38
+
37
 ## TODO references
39
 ## TODO references
38
 
40
 
39
 TODO can make reference to #X, this is github issues references.
41
 TODO can make reference to #X, this is github issues references.

+ 78 - 0
example/example_a_aiohttp.py View File

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

+ 1 - 0
hapic/__init__.py View File

20
 reset_context = _hapic_default.reset_context
20
 reset_context = _hapic_default.reset_context
21
 add_documentation_view = _hapic_default.add_documentation_view
21
 add_documentation_view = _hapic_default.add_documentation_view
22
 handle_exception = _hapic_default.handle_exception
22
 handle_exception = _hapic_default.handle_exception
23
+output_stream = _hapic_default.output_stream

+ 21 - 0
hapic/async.py View File

1
+# -*- coding: utf-8 -*-
2
+from hapic.hapic import Hapic
3
+
4
+_hapic_default = Hapic(async_=True)
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
+input_files = _hapic_default.input_files
13
+output_headers = _hapic_default.output_headers
14
+output_body = _hapic_default.output_body
15
+output_file = _hapic_default.output_file
16
+generate_doc = _hapic_default.generate_doc
17
+set_context = _hapic_default.set_context
18
+reset_context = _hapic_default.reset_context
19
+add_documentation_view = _hapic_default.add_documentation_view
20
+handle_exception = _hapic_default.handle_exception
21
+output_stream = _hapic_default.output_stream

+ 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
 

+ 223 - 17
hapic/decorator.py View File

73
         self,
73
         self,
74
         func: 'typing.Callable[..., typing.Any]',
74
         func: 'typing.Callable[..., typing.Any]',
75
     ) -> 'typing.Callable[..., typing.Any]':
75
     ) -> 'typing.Callable[..., typing.Any]':
76
+        # async def wrapper(*args, **kwargs) -> typing.Any:
76
         def wrapper(*args, **kwargs) -> typing.Any:
77
         def wrapper(*args, **kwargs) -> typing.Any:
77
             # Note: Design of before_wrapped_func can be to update kwargs
78
             # Note: Design of before_wrapped_func can be to update kwargs
78
             # by reference here
79
             # by reference here
79
             replacement_response = self.before_wrapped_func(args, kwargs)
80
             replacement_response = self.before_wrapped_func(args, kwargs)
80
-            if replacement_response:
81
+            if replacement_response is not None:
81
                 return replacement_response
82
                 return replacement_response
82
 
83
 
83
             response = self._execute_wrapped_function(func, args, kwargs)
84
             response = self._execute_wrapped_function(func, args, kwargs)
190
         return error_response
191
         return error_response
191
 
192
 
192
 
193
 
194
+# TODO BS 2018-07-23: This class is an async version of InputControllerWrapper
195
+# (and ControllerWrapper.get_wrapper rewrite) to permit async compatibility.
196
+# Please re-think about code refact. TAG: REFACT_ASYNC
197
+class AsyncInputControllerWrapper(InputControllerWrapper):
198
+    def get_wrapper(
199
+        self,
200
+        func: 'typing.Callable[..., typing.Any]',
201
+    ) -> 'typing.Callable[..., typing.Any]':
202
+        async def wrapper(*args, **kwargs) -> typing.Any:
203
+            # Note: Design of before_wrapped_func can be to update kwargs
204
+            # by reference here
205
+            replacement_response = await self.before_wrapped_func(args, kwargs)
206
+            if replacement_response is not None:
207
+                return replacement_response
208
+
209
+            response = await self._execute_wrapped_function(func, args, kwargs)
210
+            new_response = self.after_wrapped_function(response)
211
+            return new_response
212
+        return functools.update_wrapper(wrapper, func)
213
+
214
+    async def before_wrapped_func(
215
+        self,
216
+        func_args: typing.Tuple[typing.Any, ...],
217
+        func_kwargs: typing.Dict[str, typing.Any],
218
+    ) -> typing.Any:
219
+        # Retrieve hapic_data instance or create new one
220
+        # hapic_data is given though decorators
221
+        # Important note here: func_kwargs is update by reference !
222
+        hapic_data = self.ensure_hapic_data(func_kwargs)
223
+        request_parameters = self.get_request_parameters(
224
+            func_args,
225
+            func_kwargs,
226
+        )
227
+
228
+        try:
229
+            processed_data = await self.get_processed_data(request_parameters)
230
+            self.update_hapic_data(hapic_data, processed_data)
231
+        except ProcessException:
232
+            error_response = await self.get_error_response(request_parameters)
233
+            return error_response
234
+
235
+    async def get_processed_data(
236
+        self,
237
+        request_parameters: RequestParameters,
238
+    ) -> typing.Any:
239
+        parameters_data = await self.get_parameters_data(request_parameters)
240
+        processed_data = self.processor.process(parameters_data)
241
+        return processed_data
242
+
243
+
193
 class OutputControllerWrapper(InputOutputControllerWrapper):
244
 class OutputControllerWrapper(InputOutputControllerWrapper):
194
     def __init__(
245
     def __init__(
195
         self,
246
         self,
262
     pass
313
     pass
263
 
314
 
264
 
315
 
316
+# TODO BS 2018-07-23: This class is an async version of
317
+# OutputBodyControllerWrapper (ControllerWrapper.get_wrapper rewrite)
318
+# to permit async compatibility.
319
+# Please re-think about code refact. TAG: REFACT_ASYNC
320
+class AsyncOutputBodyControllerWrapper(OutputControllerWrapper):
321
+    def get_wrapper(
322
+        self,
323
+        func: 'typing.Callable[..., typing.Any]',
324
+    ) -> 'typing.Callable[..., typing.Any]':
325
+        # async def wrapper(*args, **kwargs) -> typing.Any:
326
+        async def wrapper(*args, **kwargs) -> typing.Any:
327
+            # Note: Design of before_wrapped_func can be to update kwargs
328
+            # by reference here
329
+            replacement_response = self.before_wrapped_func(args, kwargs)
330
+            if replacement_response is not None:
331
+                return replacement_response
332
+
333
+            response = await self._execute_wrapped_function(func, args, kwargs)
334
+            new_response = self.after_wrapped_function(response)
335
+            return new_response
336
+        return functools.update_wrapper(wrapper, func)
337
+
338
+
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
+
360
+    def get_wrapper(
361
+        self,
362
+        func: 'typing.Callable[..., typing.Any]',
363
+    ) -> 'typing.Callable[..., typing.Any]':
364
+        # async def wrapper(*args, **kwargs) -> typing.Any:
365
+        async def wrapper(*args, **kwargs) -> typing.Any:
366
+            # Note: Design of before_wrapped_func can be to update kwargs
367
+            # by reference here
368
+            replacement_response = self.before_wrapped_func(args, kwargs)
369
+            if replacement_response is not None:
370
+                return replacement_response
371
+
372
+            stream_response = await self.context.get_stream_response_object(
373
+                args,
374
+                kwargs,
375
+            )
376
+            async for stream_item in await self._execute_wrapped_function(
377
+                func,
378
+                args,
379
+                kwargs,
380
+            ):
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
392
+
393
+            return stream_response
394
+
395
+        return functools.update_wrapper(wrapper, func)
396
+
397
+    def _get_serialized_item(
398
+        self,
399
+        item_object: typing.Any,
400
+    ) -> dict:
401
+        return self.processor.process(item_object)
402
+
403
+
265
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
404
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
266
     pass
405
     pass
267
 
406
 
344
         return request_parameters.body_parameters
483
         return request_parameters.body_parameters
345
 
484
 
346
 
485
 
486
+# TODO BS 2018-07-23: This class is an async version of InputControllerWrapper
487
+# to permit async compatibility. Please re-think about code refact
488
+# TAG: REFACT_ASYNC
489
+class AsyncInputBodyControllerWrapper(AsyncInputControllerWrapper):
490
+    def update_hapic_data(
491
+        self, hapic_data: HapicData,
492
+        processed_data: typing.Any,
493
+    ) -> None:
494
+        hapic_data.body = processed_data
495
+
496
+    async def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
497
+        return await request_parameters.body_parameters
498
+
499
+    async def get_error_response(
500
+        self,
501
+        request_parameters: RequestParameters,
502
+    ) -> typing.Any:
503
+        parameters_data = await self.get_parameters_data(request_parameters)
504
+        error = self.processor.get_validation_error(parameters_data)
505
+        error_response = self.context.get_validation_error_response(
506
+            error,
507
+            http_code=self.error_http_code,
508
+        )
509
+        return error_response
510
+
511
+
347
 class InputHeadersControllerWrapper(InputControllerWrapper):
512
 class InputHeadersControllerWrapper(InputControllerWrapper):
348
     def update_hapic_data(
513
     def update_hapic_data(
349
         self, hapic_data: HapicData,
514
         self, hapic_data: HapicData,
420
                 func_kwargs,
585
                 func_kwargs,
421
             )
586
             )
422
         except self.handled_exception_class as exc:
587
         except self.handled_exception_class as exc:
423
-            response_content = self.error_builder.build_from_exception(
424
-                exc,
425
-                include_traceback=self.context.is_debug(),
426
-            )
588
+            return self._build_error_response(exc)
427
 
589
 
428
-            # Check error format
429
-            dumped = self.error_builder.dump(response_content).data
430
-            unmarshall = self.error_builder.load(dumped)
431
-            if unmarshall.errors:
432
-                raise OutputValidationException(
433
-                    'Validation error during dump of error response: {}'
590
+    def _build_error_response(self, exc: Exception) -> typing.Any:
591
+        response_content = self.error_builder.build_from_exception(
592
+            exc,
593
+            include_traceback=self.context.is_debug(),
594
+        )
595
+
596
+        # Check error format
597
+        dumped = self.error_builder.dump(response_content).data
598
+        unmarshall = self.error_builder.load(dumped)
599
+        if unmarshall.errors:
600
+            raise OutputValidationException(
601
+                'Validation error during dump of error response: {}'
434
                     .format(
602
                     .format(
435
-                        str(unmarshall.errors)
436
-                    )
603
+                    str(unmarshall.errors)
437
                 )
604
                 )
605
+            )
606
+
607
+        error_response = self.context.get_response(
608
+            json.dumps(dumped),
609
+            self.http_code,
610
+        )
611
+        return error_response
612
+
613
+
614
+# TODO BS 2018-07-23: This class is an async version of
615
+# ExceptionHandlerControllerWrapper
616
+# to permit async compatibility. Please re-think about code refact
617
+# TAG: REFACT_ASYNC
618
+class AsyncExceptionHandlerControllerWrapper(ExceptionHandlerControllerWrapper):
619
+    def get_wrapper(
620
+        self,
621
+        func: 'typing.Callable[..., typing.Any]',
622
+    ) -> 'typing.Callable[..., typing.Any]':
623
+        # async def wrapper(*args, **kwargs) -> typing.Any:
624
+        async def wrapper(*args, **kwargs) -> typing.Any:
625
+            # Note: Design of before_wrapped_func can be to update kwargs
626
+            # by reference here
627
+            replacement_response = self.before_wrapped_func(args, kwargs)
628
+            if replacement_response is not None:
629
+                return replacement_response
438
 
630
 
439
-            error_response = self.context.get_response(
440
-                json.dumps(dumped),
441
-                self.http_code,
631
+            response = await self._execute_wrapped_function(func, args, kwargs)
632
+            new_response = self.after_wrapped_function(response)
633
+            return new_response
634
+        return functools.update_wrapper(wrapper, func)
635
+
636
+    async def _execute_wrapped_function(
637
+        self,
638
+        func,
639
+        func_args,
640
+        func_kwargs,
641
+    ) -> typing.Any:
642
+        try:
643
+            return await super()._execute_wrapped_function(
644
+                func,
645
+                func_args,
646
+                func_kwargs,
442
             )
647
             )
443
-            return error_response
648
+        except self.handled_exception_class as exc:
649
+            return self._build_error_response(exc)

+ 6 - 0
hapic/description.py View File

38
     pass
38
     pass
39
 
39
 
40
 
40
 
41
+class OutputStreamDescription(Description):
42
+    pass
43
+
44
+
41
 class OutputFileDescription(Description):
45
 class OutputFileDescription(Description):
42
     pass
46
     pass
43
 
47
 
60
         input_forms: InputFormsDescription=None,
64
         input_forms: InputFormsDescription=None,
61
         input_files: InputFilesDescription=None,
65
         input_files: InputFilesDescription=None,
62
         output_body: OutputBodyDescription=None,
66
         output_body: OutputBodyDescription=None,
67
+        output_stream: OutputStreamDescription=None,
63
         output_file: OutputFileDescription=None,
68
         output_file: OutputFileDescription=None,
64
         output_headers: OutputHeadersDescription=None,
69
         output_headers: OutputHeadersDescription=None,
65
         errors: typing.List[ErrorDescription]=None,
70
         errors: typing.List[ErrorDescription]=None,
72
         self.input_forms = input_forms
77
         self.input_forms = input_forms
73
         self.input_files = input_files
78
         self.input_files = input_files
74
         self.output_body = output_body
79
         self.output_body = output_body
80
+        self.output_stream = output_stream
75
         self.output_file = output_file
81
         self.output_file = output_file
76
         self.output_headers = output_headers
82
         self.output_headers = output_headers
77
         self.errors = errors or []
83
         self.errors = errors or []

+ 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 - 0
hapic/ext/aiohttp/__init__.py View File

1
+# coding: utf-8

+ 276 - 0
hapic/ext/aiohttp/context.py View File

1
+# coding: utf-8
2
+import json
3
+import re
4
+import typing
5
+from http import HTTPStatus
6
+
7
+from aiohttp.web_request import Request
8
+from aiohttp.web_response import Response
9
+from multidict import MultiDict
10
+
11
+from hapic.context import BaseContext
12
+from hapic.context import RouteRepresentation
13
+from hapic.decorator import DecoratedController
14
+from hapic.decorator import DECORATION_ATTRIBUTE_NAME
15
+from hapic.error import ErrorBuilderInterface
16
+from hapic.error import DefaultErrorBuilder
17
+from hapic.exception import WorkflowException
18
+from hapic.exception import OutputValidationException
19
+from hapic.exception import NoRoutesException
20
+from hapic.exception import RouteNotFound
21
+from hapic.processor import ProcessValidationError
22
+from hapic.processor import RequestParameters
23
+from aiohttp import web
24
+
25
+
26
+# Aiohttp regular expression to locate url parameters
27
+AIOHTTP_RE_PATH_URL = re.compile(r'{([^:<>]+)(?::[^<>]+)?}')
28
+
29
+
30
+class AiohttpRequestParameters(object):
31
+    def __init__(
32
+        self,
33
+        request: Request,
34
+    ) -> None:
35
+        self._request = request
36
+        self._parsed_body = None
37
+
38
+    @property
39
+    async def body_parameters(self) -> dict:
40
+        if self._parsed_body is None:
41
+            content_type = self.header_parameters.get('Content-Type')
42
+            is_json = content_type == 'application/json'
43
+
44
+            if is_json:
45
+                self._parsed_body = await self._request.json()
46
+            else:
47
+                self._parsed_body = await self._request.post()
48
+
49
+        return self._parsed_body
50
+
51
+    @property
52
+    def path_parameters(self):
53
+        return dict(self._request.match_info)
54
+
55
+    @property
56
+    def query_parameters(self):
57
+        return MultiDict(self._request.query.items())
58
+
59
+    @property
60
+    def form_parameters(self):
61
+        # TODO BS 2018-07-24: There is misunderstanding around body/form/json
62
+        return self.body_parameters
63
+
64
+    @property
65
+    def header_parameters(self):
66
+        return dict(self._request.headers.items())
67
+
68
+    @property
69
+    def files_parameters(self):
70
+        # TODO BS 2018-07-24: To do
71
+        raise NotImplementedError('todo')
72
+
73
+
74
+class AiohttpContext(BaseContext):
75
+    def __init__(
76
+        self,
77
+        app: web.Application,
78
+        default_error_builder: ErrorBuilderInterface=None,
79
+        debug: bool = False,
80
+    ) -> None:
81
+        self._app = app
82
+        self._debug = debug
83
+        self.default_error_builder = \
84
+            default_error_builder or DefaultErrorBuilder()  # FDV
85
+
86
+    @property
87
+    def app(self) -> web.Application:
88
+        return self._app
89
+
90
+    def get_request_parameters(
91
+        self,
92
+        *args,
93
+        **kwargs
94
+    ) -> RequestParameters:
95
+        try:
96
+            request = args[0]
97
+        except IndexError:
98
+            raise WorkflowException(
99
+                'Unable to get aiohttp request object',
100
+            )
101
+        request = typing.cast(Request, request)
102
+        return AiohttpRequestParameters(request)
103
+
104
+    def get_response(
105
+        self,
106
+        response: str,
107
+        http_code: int,
108
+        mimetype: str = 'application/json',
109
+    ) -> typing.Any:
110
+        # A 204 no content response should not have content type header
111
+        if http_code == HTTPStatus.NO_CONTENT:
112
+            mimetype = None
113
+            response = ''
114
+
115
+        return Response(
116
+            body=response,
117
+            status=http_code,
118
+            content_type=mimetype,
119
+        )
120
+
121
+    def get_validation_error_response(
122
+        self,
123
+        error: ProcessValidationError,
124
+        http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
125
+    ) -> typing.Any:
126
+        error_builder = self.get_default_error_builder()
127
+        error_content = error_builder.build_from_validation_error(
128
+            error,
129
+        )
130
+
131
+        # Check error
132
+        dumped = error_builder.dump(error_content).data
133
+        unmarshall = error_builder.load(dumped)
134
+        if unmarshall.errors:
135
+            raise OutputValidationException(
136
+                'Validation error during dump of error response: {}'.format(
137
+                    str(unmarshall.errors)
138
+                )
139
+            )
140
+
141
+        return web.Response(
142
+            text=json.dumps(dumped),
143
+            headers=[
144
+                ('Content-Type', 'application/json'),
145
+            ],
146
+            status=int(http_code),
147
+        )
148
+
149
+    def find_route(
150
+        self,
151
+        decorated_controller: DecoratedController,
152
+    ) -> RouteRepresentation:
153
+        if not len(self.app.router.routes()):
154
+            raise NoRoutesException('There is no routes in your aiohttp app')
155
+
156
+        reference = decorated_controller.reference
157
+
158
+        for route in self.app.router.routes():
159
+            route_token = getattr(
160
+                route.handler,
161
+                DECORATION_ATTRIBUTE_NAME,
162
+                None,
163
+            )
164
+
165
+            match_with_wrapper = route.handler == reference.wrapper
166
+            match_with_wrapped = route.handler == reference.wrapped
167
+            match_with_token = route_token == reference.token
168
+
169
+            # TODO BS 2018-07-27: token is set in HEAD view to, must solve this
170
+            # case
171
+            if not match_with_wrapper \
172
+                    and not match_with_wrapped \
173
+                    and match_with_token \
174
+                    and route.method.lower() == 'head':
175
+                continue
176
+
177
+            if match_with_wrapper or match_with_wrapped or match_with_token:
178
+                return RouteRepresentation(
179
+                    rule=self.get_swagger_path(route.resource.canonical),
180
+                    method=route.method.lower(),
181
+                    original_route_object=route,
182
+                )
183
+        # TODO BS 20171010: Raise exception or print error ? see #10
184
+        raise RouteNotFound(
185
+            'Decorated route "{}" was not found in aiohttp routes'.format(
186
+                decorated_controller.name,
187
+            )
188
+        )
189
+
190
+    def get_swagger_path(
191
+        self,
192
+        contextualised_rule: str,
193
+    ) -> str:
194
+        return AIOHTTP_RE_PATH_URL.sub(r'{\1}', contextualised_rule)
195
+
196
+    def by_pass_output_wrapping(
197
+        self,
198
+        response: typing.Any,
199
+    ) -> bool:
200
+        return isinstance(response, web.Response)
201
+
202
+    def add_view(
203
+        self,
204
+        route: str,
205
+        http_method: str,
206
+        view_func: typing.Callable[..., typing.Any],
207
+    ) -> None:
208
+        # TODO BS 2018-07-15: to do
209
+        raise NotImplementedError('todo')
210
+
211
+    def serve_directory(
212
+        self,
213
+        route_prefix: str,
214
+        directory_path: str,
215
+    ) -> None:
216
+        # TODO BS 2018-07-15: to do
217
+        raise NotImplementedError('todo')
218
+
219
+    def is_debug(
220
+        self,
221
+    ) -> bool:
222
+        return self._debug
223
+
224
+    def handle_exception(
225
+        self,
226
+        exception_class: typing.Type[Exception],
227
+        http_code: int,
228
+    ) -> None:
229
+        # TODO BS 2018-07-15: to do
230
+        raise NotImplementedError('todo')
231
+
232
+    def handle_exceptions(
233
+        self,
234
+        exception_classes: typing.List[typing.Type[Exception]],
235
+        http_code: int,
236
+    ) -> None:
237
+        # TODO BS 2018-07-15: to do
238
+        raise NotImplementedError('todo')
239
+
240
+    async def get_stream_response_object(
241
+        self,
242
+        func_args,
243
+        func_kwargs,
244
+        http_code: HTTPStatus = HTTPStatus.OK,
245
+        headers: dict = None,
246
+    ) -> web.StreamResponse:
247
+        headers = headers or {
248
+            'Content-Type': 'text/plain; charset=utf-8',
249
+        }
250
+
251
+        response = web.StreamResponse(
252
+            status=http_code,
253
+            headers=headers,
254
+        )
255
+
256
+        try:
257
+            request = func_args[0]
258
+        except IndexError:
259
+            raise WorkflowException(
260
+                'Unable to get aiohttp request object',
261
+            )
262
+        request = typing.cast(Request, request)
263
+
264
+        await response.prepare(request)
265
+
266
+        return response
267
+
268
+    async def feed_stream_response(
269
+        self,
270
+        stream_response: web.StreamResponse,
271
+        serialized_item: dict,
272
+    ) -> None:
273
+        await stream_response.write(
274
+            # FIXME BS 2018-07-25: need \n :/
275
+            json.dumps(serialized_item).encode('utf-8') + b'\n',
276
+        )

+ 98 - 19
hapic/hapic.py View File

14
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
14
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
15
 from hapic.decorator import ControllerReference
15
 from hapic.decorator import ControllerReference
16
 from hapic.decorator import ExceptionHandlerControllerWrapper
16
 from hapic.decorator import ExceptionHandlerControllerWrapper
17
+from hapic.decorator import AsyncExceptionHandlerControllerWrapper
17
 from hapic.decorator import InputBodyControllerWrapper
18
 from hapic.decorator import InputBodyControllerWrapper
19
+from hapic.decorator import AsyncInputBodyControllerWrapper
18
 from hapic.decorator import InputHeadersControllerWrapper
20
 from hapic.decorator import InputHeadersControllerWrapper
19
 from hapic.decorator import InputPathControllerWrapper
21
 from hapic.decorator import InputPathControllerWrapper
20
 from hapic.decorator import InputQueryControllerWrapper
22
 from hapic.decorator import InputQueryControllerWrapper
21
 from hapic.decorator import InputFilesControllerWrapper
23
 from hapic.decorator import InputFilesControllerWrapper
22
 from hapic.decorator import OutputBodyControllerWrapper
24
 from hapic.decorator import OutputBodyControllerWrapper
25
+from hapic.decorator import AsyncOutputBodyControllerWrapper
26
+from hapic.decorator import AsyncOutputStreamControllerWrapper
23
 from hapic.decorator import OutputHeadersControllerWrapper
27
 from hapic.decorator import OutputHeadersControllerWrapper
24
 from hapic.decorator import OutputFileControllerWrapper
28
 from hapic.decorator import OutputFileControllerWrapper
25
 from hapic.description import InputBodyDescription
29
 from hapic.description import InputBodyDescription
30
 from hapic.description import InputQueryDescription
34
 from hapic.description import InputQueryDescription
31
 from hapic.description import InputFilesDescription
35
 from hapic.description import InputFilesDescription
32
 from hapic.description import OutputBodyDescription
36
 from hapic.description import OutputBodyDescription
37
+from hapic.description import OutputStreamDescription
33
 from hapic.description import OutputHeadersDescription
38
 from hapic.description import OutputHeadersDescription
34
 from hapic.description import OutputFileDescription
39
 from hapic.description import OutputFileDescription
35
 from hapic.doc import DocGenerator
40
 from hapic.doc import DocGenerator
45
 
50
 
46
 
51
 
47
 class Hapic(object):
52
 class Hapic(object):
48
-    def __init__(self):
53
+    def __init__(
54
+        self,
55
+        async_: bool = False,
56
+    ):
49
         self._buffer = DecorationBuffer()
57
         self._buffer = DecorationBuffer()
50
         self._controllers = []  # type: typing.List[DecoratedController]
58
         self._controllers = []  # type: typing.List[DecoratedController]
51
         self._context = None  # type: ContextInterface
59
         self._context = None  # type: ContextInterface
52
         self._error_builder = None  # type: ErrorBuilderInterface
60
         self._error_builder = None  # type: ErrorBuilderInterface
61
+        self._async = async_
53
         self.doc_generator = DocGenerator()
62
         self.doc_generator = DocGenerator()
54
 
63
 
55
         # This local function will be pass to different components
64
         # This local function will be pass to different components
143
         processor = processor or MarshmallowOutputProcessor()
152
         processor = processor or MarshmallowOutputProcessor()
144
         processor.schema = schema
153
         processor.schema = schema
145
         context = context or self._context_getter
154
         context = context or self._context_getter
146
-        decoration = OutputBodyControllerWrapper(
147
-            context=context,
148
-            processor=processor,
149
-            error_http_code=error_http_code,
150
-            default_http_code=default_http_code,
151
-        )
155
+
156
+        if self._async:
157
+            decoration = AsyncOutputBodyControllerWrapper(
158
+                context=context,
159
+                processor=processor,
160
+                error_http_code=error_http_code,
161
+                default_http_code=default_http_code,
162
+            )
163
+        else:
164
+            decoration = OutputBodyControllerWrapper(
165
+                context=context,
166
+                processor=processor,
167
+                error_http_code=error_http_code,
168
+                default_http_code=default_http_code,
169
+            )
152
 
170
 
153
         def decorator(func):
171
         def decorator(func):
154
             self._buffer.output_body = OutputBodyDescription(decoration)
172
             self._buffer.output_body = OutputBodyDescription(decoration)
155
             return decoration.get_wrapper(func)
173
             return decoration.get_wrapper(func)
156
         return decorator
174
         return decorator
157
 
175
 
176
+    def output_stream(
177
+        self,
178
+        item_schema: typing.Any,
179
+        processor: ProcessorInterface = None,
180
+        context: ContextInterface = None,
181
+        error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
182
+        default_http_code: HTTPStatus = HTTPStatus.OK,
183
+        ignore_on_error: bool = True,
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
+        """
199
+        processor = processor or MarshmallowOutputProcessor()
200
+        processor.schema = item_schema
201
+        context = context or self._context_getter
202
+
203
+        if self._async:
204
+            decoration = AsyncOutputStreamControllerWrapper(
205
+                context=context,
206
+                processor=processor,
207
+                error_http_code=error_http_code,
208
+                default_http_code=default_http_code,
209
+                ignore_on_error=ignore_on_error,
210
+            )
211
+        else:
212
+            # TODO BS 2018-07-25: To do
213
+            raise NotImplementedError('todo')
214
+
215
+        def decorator(func):
216
+            self._buffer.output_stream = OutputStreamDescription(decoration)
217
+            return decoration.get_wrapper(func)
218
+        return decorator
219
+
158
     def output_headers(
220
     def output_headers(
159
         self,
221
         self,
160
         schema: typing.Any,
222
         schema: typing.Any,
282
         processor.schema = schema
344
         processor.schema = schema
283
         context = context or self._context_getter
345
         context = context or self._context_getter
284
 
346
 
285
-        decoration = InputBodyControllerWrapper(
286
-            context=context,
287
-            processor=processor,
288
-            error_http_code=error_http_code,
289
-            default_http_code=default_http_code,
290
-        )
347
+        if self._async:
348
+            decoration = AsyncInputBodyControllerWrapper(
349
+                context=context,
350
+                processor=processor,
351
+                error_http_code=error_http_code,
352
+                default_http_code=default_http_code,
353
+            )
354
+        else:
355
+            decoration = InputBodyControllerWrapper(
356
+                context=context,
357
+                processor=processor,
358
+                error_http_code=error_http_code,
359
+                default_http_code=default_http_code,
360
+            )
291
 
361
 
292
         def decorator(func):
362
         def decorator(func):
293
             self._buffer.input_body = InputBodyDescription(decoration)
363
             self._buffer.input_body = InputBodyDescription(decoration)
352
         context = context or self._context_getter
422
         context = context or self._context_getter
353
         error_builder = error_builder or self._error_builder_getter
423
         error_builder = error_builder or self._error_builder_getter
354
 
424
 
355
-        decoration = ExceptionHandlerControllerWrapper(
356
-            handled_exception_class,
357
-            context,
358
-            error_builder=error_builder,
359
-            http_code=http_code,
360
-        )
425
+        if self._async:
426
+            decoration = AsyncExceptionHandlerControllerWrapper(
427
+                handled_exception_class,
428
+                context,
429
+                error_builder=error_builder,
430
+                http_code=http_code,
431
+            )
432
+
433
+        else:
434
+            decoration = ExceptionHandlerControllerWrapper(
435
+                handled_exception_class,
436
+                context,
437
+                error_builder=error_builder,
438
+                http_code=http_code,
439
+            )
361
 
440
 
362
         def decorator(func):
441
         def decorator(func):
363
             self._buffer.errors.append(ErrorDescription(decoration))
442
             self._buffer.errors.append(ErrorDescription(decoration))

+ 1 - 1
hapic/infos.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-__version__ = '0.43'
2
+__version__ = '0.46'

+ 0 - 0
pocold.py View File


+ 2 - 0
setup.py View File

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

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

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_handle_excpetion__ok__nominal_case(
191
+        self,
192
+        aiohttp_client,
193
+        loop,
194
+    ):
195
+        hapic = Hapic(async_=True)
196
+
197
+        @hapic.handle_exception(ZeroDivisionError, http_code=400)
198
+        async def hello(request):
199
+            1 / 0
200
+
201
+        app = web.Application(debug=True)
202
+        app.router.add_get('/', hello)
203
+        hapic.set_context(AiohttpContext(app))
204
+        client = await aiohttp_client(app)
205
+
206
+        resp = await client.get('/')
207
+        assert resp.status == 400
208
+
209
+        data = await resp.json()
210
+        assert 'division by zero' == data.get('message')
211
+
212
+    async def test_aiohttp_output_stream__ok__nominal_case(
213
+        self,
214
+        aiohttp_client,
215
+        loop,
216
+    ):
217
+        hapic = Hapic(async_=True)
218
+
219
+        class AsyncGenerator:
220
+            def __init__(self):
221
+                self._iterator = iter([
222
+                    {'name': 'Hello, bob'},
223
+                    {'name': 'Hello, franck'},
224
+                ])
225
+
226
+            async def __aiter__(self):
227
+                return self
228
+
229
+            async def __anext__(self):
230
+                return next(self._iterator)
231
+
232
+        class OuputStreamItemSchema(marshmallow.Schema):
233
+            name = marshmallow.fields.String()
234
+
235
+        @hapic.output_stream(OuputStreamItemSchema())
236
+        async def hello(request):
237
+            return AsyncGenerator()
238
+
239
+        app = web.Application(debug=True)
240
+        app.router.add_get('/', hello)
241
+        hapic.set_context(AiohttpContext(app))
242
+        client = await aiohttp_client(app)
243
+
244
+        resp = await client.get('/')
245
+        assert resp.status == 200
246
+
247
+        line = await resp.content.readline()
248
+        assert b'{"name": "Hello, bob"}\n' == line
249
+
250
+        line = await resp.content.readline()
251
+        assert b'{"name": "Hello, franck"}\n' == line
252
+
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
336
+
337
+    def test_unit__generate_doc__ok__nominal_case(
338
+        self,
339
+        aiohttp_client,
340
+        loop,
341
+    ):
342
+        hapic = Hapic(async_=True)
343
+
344
+        class InputPathSchema(marshmallow.Schema):
345
+            username = marshmallow.fields.String(required=True)
346
+
347
+        class InputQuerySchema(marshmallow.Schema):
348
+            show_deleted = marshmallow.fields.Boolean(required=False)
349
+
350
+        class UserSchema(marshmallow.Schema):
351
+            name = marshmallow.fields.String(required=True)
352
+
353
+        @hapic.with_api_doc()
354
+        @hapic.input_path(InputPathSchema())
355
+        @hapic.input_query(InputQuerySchema())
356
+        @hapic.output_body(UserSchema())
357
+        async def get_user(request, hapic_data):
358
+            pass
359
+
360
+        @hapic.with_api_doc()
361
+        @hapic.input_path(InputPathSchema())
362
+        @hapic.output_body(UserSchema())
363
+        async def post_user(request, hapic_data):
364
+            pass
365
+
366
+        app = web.Application(debug=True)
367
+        app.router.add_get('/{username}', get_user)
368
+        app.router.add_post('/{username}', post_user)
369
+        hapic.set_context(AiohttpContext(app))
370
+
371
+        doc = hapic.generate_doc('aiohttp', 'testing')
372
+        assert 'UserSchema' in doc.get('definitions')
373
+        assert {
374
+                   'name': {'type': 'string'}
375
+               } == doc['definitions']['UserSchema'].get('properties')
376
+        assert '/{username}' in doc.get('paths')
377
+        assert 'get' in doc['paths']['/{username}']
378
+        assert 'post' in doc['paths']['/{username}']
379
+
380
+        assert [
381
+            {
382
+                'name': 'username',
383
+                'in': 'path',
384
+                'required': True,
385
+                'type': 'string',
386
+            },
387
+            {
388
+                'name': 'show_deleted',
389
+                'in': 'query',
390
+                'required': False,
391
+                'type': 'boolean',
392
+            }
393
+        ] == doc['paths']['/{username}']['get']['parameters']
394
+        assert {
395
+            200: {
396
+                'schema': {
397
+                    '$ref': '#/definitions/UserSchema',
398
+                },
399
+                'description': '200',
400
+            }
401
+        } == doc['paths']['/{username}']['get']['responses']
402
+
403
+        assert [
404
+                   {
405
+                       'name': 'username',
406
+                       'in': 'path',
407
+                       'required': True,
408
+                       'type': 'string',
409
+                   }
410
+               ] == doc['paths']['/{username}']['post']['parameters']
411
+        assert {
412
+                   200: {
413
+                       'schema': {
414
+                           '$ref': '#/definitions/UserSchema',
415
+                       },
416
+                       'description': '200',
417
+                   }
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']