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,7 +1,6 @@
1 1
 sudo: false
2 2
 language: python
3 3
 python:
4
-  - "3.4"
5 4
   - "3.5"
6 5
   - "3.6"
7 6
 

+ 2 - 0
README.md View File

@@ -34,6 +34,8 @@ Hapic source code is ready for production. Some refactoring are identified and r
34 34
 
35 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 39
 ## TODO references
38 40
 
39 41
 TODO can make reference to #X, this is github issues references.

+ 78 - 0
example/example_a_aiohttp.py View File

@@ -0,0 +1,78 @@
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,3 +20,4 @@ set_context = _hapic_default.set_context
20 20
 reset_context = _hapic_default.reset_context
21 21
 add_documentation_view = _hapic_default.add_documentation_view
22 22
 handle_exception = _hapic_default.handle_exception
23
+output_stream = _hapic_default.output_stream

+ 21 - 0
hapic/async.py View File

@@ -0,0 +1,21 @@
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,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
 

+ 223 - 17
hapic/decorator.py View File

@@ -73,11 +73,12 @@ class ControllerWrapper(object):
73 73
         self,
74 74
         func: 'typing.Callable[..., typing.Any]',
75 75
     ) -> 'typing.Callable[..., typing.Any]':
76
+        # async def wrapper(*args, **kwargs) -> typing.Any:
76 77
         def wrapper(*args, **kwargs) -> typing.Any:
77 78
             # Note: Design of before_wrapped_func can be to update kwargs
78 79
             # by reference here
79 80
             replacement_response = self.before_wrapped_func(args, kwargs)
80
-            if replacement_response:
81
+            if replacement_response is not None:
81 82
                 return replacement_response
82 83
 
83 84
             response = self._execute_wrapped_function(func, args, kwargs)
@@ -190,6 +191,56 @@ class InputControllerWrapper(InputOutputControllerWrapper):
190 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 244
 class OutputControllerWrapper(InputOutputControllerWrapper):
194 245
     def __init__(
195 246
         self,
@@ -262,6 +313,94 @@ class OutputBodyControllerWrapper(OutputControllerWrapper):
262 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 404
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
266 405
     pass
267 406
 
@@ -344,6 +483,32 @@ class InputBodyControllerWrapper(InputControllerWrapper):
344 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 512
 class InputHeadersControllerWrapper(InputControllerWrapper):
348 513
     def update_hapic_data(
349 514
         self, hapic_data: HapicData,
@@ -420,24 +585,65 @@ class ExceptionHandlerControllerWrapper(ControllerWrapper):
420 585
                 func_kwargs,
421 586
             )
422 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 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,6 +38,10 @@ class OutputBodyDescription(Description):
38 38
     pass
39 39
 
40 40
 
41
+class OutputStreamDescription(Description):
42
+    pass
43
+
44
+
41 45
 class OutputFileDescription(Description):
42 46
     pass
43 47
 
@@ -60,6 +64,7 @@ class ControllerDescription(object):
60 64
         input_forms: InputFormsDescription=None,
61 65
         input_files: InputFilesDescription=None,
62 66
         output_body: OutputBodyDescription=None,
67
+        output_stream: OutputStreamDescription=None,
63 68
         output_file: OutputFileDescription=None,
64 69
         output_headers: OutputHeadersDescription=None,
65 70
         errors: typing.List[ErrorDescription]=None,
@@ -72,6 +77,7 @@ class ControllerDescription(object):
72 77
         self.input_forms = input_forms
73 78
         self.input_files = input_files
74 79
         self.output_body = output_body
80
+        self.output_stream = output_stream
75 81
         self.output_file = output_file
76 82
         self.output_headers = output_headers
77 83
         self.errors = errors or []

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

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

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

@@ -0,0 +1,276 @@
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,12 +14,16 @@ from hapic.decorator import DecoratedController
14 14
 from hapic.decorator import DECORATION_ATTRIBUTE_NAME
15 15
 from hapic.decorator import ControllerReference
16 16
 from hapic.decorator import ExceptionHandlerControllerWrapper
17
+from hapic.decorator import AsyncExceptionHandlerControllerWrapper
17 18
 from hapic.decorator import InputBodyControllerWrapper
19
+from hapic.decorator import AsyncInputBodyControllerWrapper
18 20
 from hapic.decorator import InputHeadersControllerWrapper
19 21
 from hapic.decorator import InputPathControllerWrapper
20 22
 from hapic.decorator import InputQueryControllerWrapper
21 23
 from hapic.decorator import InputFilesControllerWrapper
22 24
 from hapic.decorator import OutputBodyControllerWrapper
25
+from hapic.decorator import AsyncOutputBodyControllerWrapper
26
+from hapic.decorator import AsyncOutputStreamControllerWrapper
23 27
 from hapic.decorator import OutputHeadersControllerWrapper
24 28
 from hapic.decorator import OutputFileControllerWrapper
25 29
 from hapic.description import InputBodyDescription
@@ -30,6 +34,7 @@ from hapic.description import InputPathDescription
30 34
 from hapic.description import InputQueryDescription
31 35
 from hapic.description import InputFilesDescription
32 36
 from hapic.description import OutputBodyDescription
37
+from hapic.description import OutputStreamDescription
33 38
 from hapic.description import OutputHeadersDescription
34 39
 from hapic.description import OutputFileDescription
35 40
 from hapic.doc import DocGenerator
@@ -45,11 +50,15 @@ from hapic.error import ErrorBuilderInterface
45 50
 
46 51
 
47 52
 class Hapic(object):
48
-    def __init__(self):
53
+    def __init__(
54
+        self,
55
+        async_: bool = False,
56
+    ):
49 57
         self._buffer = DecorationBuffer()
50 58
         self._controllers = []  # type: typing.List[DecoratedController]
51 59
         self._context = None  # type: ContextInterface
52 60
         self._error_builder = None  # type: ErrorBuilderInterface
61
+        self._async = async_
53 62
         self.doc_generator = DocGenerator()
54 63
 
55 64
         # This local function will be pass to different components
@@ -143,18 +152,71 @@ class Hapic(object):
143 152
         processor = processor or MarshmallowOutputProcessor()
144 153
         processor.schema = schema
145 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 171
         def decorator(func):
154 172
             self._buffer.output_body = OutputBodyDescription(decoration)
155 173
             return decoration.get_wrapper(func)
156 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 220
     def output_headers(
159 221
         self,
160 222
         schema: typing.Any,
@@ -282,12 +344,20 @@ class Hapic(object):
282 344
         processor.schema = schema
283 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 362
         def decorator(func):
293 363
             self._buffer.input_body = InputBodyDescription(decoration)
@@ -352,12 +422,21 @@ class Hapic(object):
352 422
         context = context or self._context_getter
353 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 441
         def decorator(func):
363 442
             self._buffer.errors.append(ErrorDescription(decoration))

+ 1 - 1
hapic/infos.py View File

@@ -1,2 +1,2 @@
1 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,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',

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

@@ -0,0 +1,448 @@
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']