Browse Source

Merge branch 'master' into feature/pyramid_and_generic_doc_generation

Bastien Sevajol 6 years ago
parent
commit
a63a4a0989

+ 20 - 0
.editorconfig View File

1
+# EditorConfig: http://EditorConfig.org
2
+
3
+# top-most EditorConfig file
4
+root = true
5
+
6
+# Unix-style newlines with a newline ending every file
7
+[*]
8
+end_of_line = lf
9
+insert_final_newline = true
10
+
11
+[*.py]
12
+charset = utf-8
13
+indent_style = space
14
+indent_size = 4
15
+max_line_length = 79
16
+
17
+# Matches the exact files either package.json or .travis.yml
18
+[.travis.yml]
19
+indent_style = space
20
+indent_size = 2

+ 4 - 0
example.py View File

31
         required=True,
31
         required=True,
32
         validate=marshmallow.validate.Length(min=3),
32
         validate=marshmallow.validate.Length(min=3),
33
     )
33
     )
34
+
35
+
36
+class HelloFileSchema(marshmallow.Schema):
37
+    myfile = marshmallow.fields.Raw(required=True)

+ 8 - 1
example_a.py View File

9
 
9
 
10
 import hapic
10
 import hapic
11
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
11
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
12
-    ErrorResponseSchema, HelloQuerySchema
12
+    ErrorResponseSchema, HelloQuerySchema, HelloFileSchema
13
 from hapic.data import HapicData
13
 from hapic.data import HapicData
14
 
14
 
15
 # hapic.global_exception_handler(UnAuthExc, StandardErrorSchema)
15
 # hapic.global_exception_handler(UnAuthExc, StandardErrorSchema)
96
             'name': name,
96
             'name': name,
97
         }
97
         }
98
 
98
 
99
+    @hapic.with_api_doc()
100
+    @hapic.input_files(HelloFileSchema())
101
+    @hapic.output_file(['image/jpeg'])
102
+    def hellofile(self, hapic_data: HapicData):
103
+        return hapic_data.files['myfile']
104
+
99
     def bind(self, app):
105
     def bind(self, app):
100
         app.route('/hello/<name>', callback=self.hello)
106
         app.route('/hello/<name>', callback=self.hello)
101
         app.route('/hello/<name>', callback=self.hello2, method='POST')
107
         app.route('/hello/<name>', callback=self.hello2, method='POST')
102
         app.route('/hello3/<name>', callback=self.hello3)
108
         app.route('/hello3/<name>', callback=self.hello3)
109
+        app.route('/hellofile', callback=self.hellofile)
103
 
110
 
104
 app = bottle.Bottle()
111
 app = bottle.Bottle()
105
 
112
 

+ 2 - 0
hapic/__init__.py View File

11
 input_path = _hapic_default.input_path
11
 input_path = _hapic_default.input_path
12
 input_query = _hapic_default.input_query
12
 input_query = _hapic_default.input_query
13
 input_forms = _hapic_default.input_forms
13
 input_forms = _hapic_default.input_forms
14
+input_files = _hapic_default.input_files
14
 output_headers = _hapic_default.output_headers
15
 output_headers = _hapic_default.output_headers
15
 output_body = _hapic_default.output_body
16
 output_body = _hapic_default.output_body
17
+output_file = _hapic_default.output_file
16
 generate_doc = _hapic_default.generate_doc
18
 generate_doc = _hapic_default.generate_doc
17
 set_context = _hapic_default.set_context
19
 set_context = _hapic_default.set_context
18
 handle_exception = _hapic_default.handle_exception
20
 handle_exception = _hapic_default.handle_exception

+ 22 - 0
hapic/buffer.py View File

7
 from hapic.description import InputBodyDescription
7
 from hapic.description import InputBodyDescription
8
 from hapic.description import InputHeadersDescription
8
 from hapic.description import InputHeadersDescription
9
 from hapic.description import InputFormsDescription
9
 from hapic.description import InputFormsDescription
10
+from hapic.description import InputFilesDescription
10
 from hapic.description import OutputBodyDescription
11
 from hapic.description import OutputBodyDescription
12
+from hapic.description import OutputFileDescription
11
 from hapic.description import OutputHeadersDescription
13
 from hapic.description import OutputHeadersDescription
12
 from hapic.description import ErrorDescription
14
 from hapic.description import ErrorDescription
13
 from hapic.exception import AlreadyDecoratedException
15
 from hapic.exception import AlreadyDecoratedException
74
         self._description.input_forms = description
76
         self._description.input_forms = description
75
 
77
 
76
     @property
78
     @property
79
+    def input_files(self) -> InputFilesDescription:
80
+        return self._description.input_files
81
+
82
+    @input_files.setter
83
+    def input_files(self, description: InputFilesDescription) -> None:
84
+        if self._description.input_files is not None:
85
+            raise AlreadyDecoratedException()
86
+        self._description.input_files = description
87
+
88
+    @property
77
     def output_body(self) -> OutputBodyDescription:
89
     def output_body(self) -> OutputBodyDescription:
78
         return self._description.output_body
90
         return self._description.output_body
79
 
91
 
84
         self._description.output_body = description
96
         self._description.output_body = description
85
 
97
 
86
     @property
98
     @property
99
+    def output_file(self) -> OutputFileDescription:
100
+        return self._description.output_file
101
+
102
+    @output_file.setter
103
+    def output_file(self, description: OutputFileDescription) -> None:
104
+        if self._description.output_file is not None:
105
+            raise AlreadyDecoratedException()
106
+        self._description.output_file = description
107
+
108
+    @property
87
     def output_headers(self) -> OutputHeadersDescription:
109
     def output_headers(self) -> OutputHeadersDescription:
88
         return self._description.output_headers
110
         return self._description.output_headers
89
 
111
 

+ 1 - 0
hapic/data.py View File

8
         self.query = {}
8
         self.query = {}
9
         self.headers = {}
9
         self.headers = {}
10
         self.forms = {}
10
         self.forms = {}
11
+        self.files = {}

+ 83 - 43
hapic/decorator.py View File

6
 # TODO BS 20171010: bottle specific !  # see #5
6
 # TODO BS 20171010: bottle specific !  # see #5
7
 import marshmallow
7
 import marshmallow
8
 from bottle import HTTPResponse
8
 from bottle import HTTPResponse
9
+from multidict import MultiDict
9
 
10
 
10
 from hapic.data import HapicData
11
 from hapic.data import HapicData
11
 from hapic.description import ControllerDescription
12
 from hapic.description import ControllerDescription
44
         self.wrapped = wrapped
45
         self.wrapped = wrapped
45
         self.token = token
46
         self.token = token
46
 
47
 
48
+    def get_doc_string(self) -> str:
49
+        if self.wrapper.__doc__:
50
+            return self.wrapper.__doc__.strip()
51
+
52
+        if self.wrapped.__doc__:
53
+            return self.wrapper.__doc__.strip()
54
+
55
+        return ''
56
+
47
 
57
 
48
 class ControllerWrapper(object):
58
 class ControllerWrapper(object):
49
     def before_wrapped_func(
59
     def before_wrapped_func(
150
         self,
160
         self,
151
         request_parameters: RequestParameters,
161
         request_parameters: RequestParameters,
152
     ) -> typing.Any:
162
     ) -> typing.Any:
163
+        parameters_data = self.get_parameters_data(request_parameters)
164
+        processed_data = self.processor.process(parameters_data)
165
+        return processed_data
166
+
167
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
153
         raise NotImplementedError()
168
         raise NotImplementedError()
154
 
169
 
155
     def update_hapic_data(
170
     def update_hapic_data(
163
         self,
178
         self,
164
         request_parameters: RequestParameters,
179
         request_parameters: RequestParameters,
165
     ) -> typing.Any:
180
     ) -> typing.Any:
166
-        error = self.processor.get_validation_error(
167
-            request_parameters.body_parameters,
168
-        )
181
+        parameters_data = self.get_parameters_data(request_parameters)
182
+        error = self.processor.get_validation_error(parameters_data)
169
         error_response = self.context.get_validation_error_response(
183
         error_response = self.context.get_validation_error_response(
170
             error,
184
             error,
171
             http_code=self.error_http_code,
185
             http_code=self.error_http_code,
249
     pass
263
     pass
250
 
264
 
251
 
265
 
266
+class OutputFileControllerWrapper(ControllerWrapper):
267
+    def __init__(
268
+        self,
269
+        output_types: typing.List[str],
270
+        default_http_code: HTTPStatus=HTTPStatus.OK,
271
+    ) -> None:
272
+        self.output_types = output_types
273
+        self.default_http_code = default_http_code
274
+
275
+
252
 class InputPathControllerWrapper(InputControllerWrapper):
276
 class InputPathControllerWrapper(InputControllerWrapper):
253
     def update_hapic_data(
277
     def update_hapic_data(
254
         self, hapic_data: HapicData,
278
         self, hapic_data: HapicData,
256
     ) -> None:
280
     ) -> None:
257
         hapic_data.path = processed_data
281
         hapic_data.path = processed_data
258
 
282
 
259
-    def get_processed_data(
260
-        self,
261
-        request_parameters: RequestParameters,
262
-    ) -> typing.Any:
263
-        processed_data = self.processor.process(
264
-            request_parameters.path_parameters,
265
-        )
266
-        return processed_data
283
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
284
+        return request_parameters.path_parameters
267
 
285
 
268
 
286
 
269
 class InputQueryControllerWrapper(InputControllerWrapper):
287
 class InputQueryControllerWrapper(InputControllerWrapper):
288
+    def __init__(
289
+        self,
290
+        context: typing.Union[ContextInterface, typing.Callable[[], ContextInterface]],  # nopep8
291
+        processor: ProcessorInterface,
292
+        error_http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
293
+        default_http_code: HTTPStatus=HTTPStatus.OK,
294
+        as_list: typing.List[str]=None
295
+    ) -> None:
296
+        super().__init__(
297
+            context,
298
+            processor,
299
+            error_http_code,
300
+            default_http_code,
301
+        )
302
+        self.as_list = as_list or []  # FDV
303
+
270
     def update_hapic_data(
304
     def update_hapic_data(
271
         self, hapic_data: HapicData,
305
         self, hapic_data: HapicData,
272
         processed_data: typing.Any,
306
         processed_data: typing.Any,
273
     ) -> None:
307
     ) -> None:
274
         hapic_data.query = processed_data
308
         hapic_data.query = processed_data
275
 
309
 
276
-    def get_processed_data(
277
-        self,
278
-        request_parameters: RequestParameters,
279
-    ) -> typing.Any:
280
-        processed_data = self.processor.process(
281
-            request_parameters.query_parameters,
282
-        )
283
-        return processed_data
310
+    def get_parameters_data(self, request_parameters: RequestParameters) -> MultiDict:  # nopep8
311
+        # Parameters are updated considering eventual as_list parameters
312
+        if self.as_list:
313
+            query_parameters = MultiDict()
314
+            for parameter_name in request_parameters.query_parameters.keys():
315
+                if parameter_name in query_parameters:
316
+                    continue
317
+
318
+                if parameter_name in self.as_list:
319
+                    query_parameters[parameter_name] = \
320
+                        request_parameters.query_parameters.getall(
321
+                            parameter_name,
322
+                        )
323
+                else:
324
+                    query_parameters[parameter_name] = \
325
+                        request_parameters.query_parameters.get(
326
+                            parameter_name,
327
+                        )
328
+            return query_parameters
329
+
330
+        return request_parameters.query_parameters
284
 
331
 
285
 
332
 
286
 class InputBodyControllerWrapper(InputControllerWrapper):
333
 class InputBodyControllerWrapper(InputControllerWrapper):
290
     ) -> None:
337
     ) -> None:
291
         hapic_data.body = processed_data
338
         hapic_data.body = processed_data
292
 
339
 
293
-    def get_processed_data(
294
-        self,
295
-        request_parameters: RequestParameters,
296
-    ) -> typing.Any:
297
-        processed_data = self.processor.process(
298
-            request_parameters.body_parameters,
299
-        )
300
-        return processed_data
340
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
341
+        return request_parameters.body_parameters
301
 
342
 
302
 
343
 
303
 class InputHeadersControllerWrapper(InputControllerWrapper):
344
 class InputHeadersControllerWrapper(InputControllerWrapper):
307
     ) -> None:
348
     ) -> None:
308
         hapic_data.headers = processed_data
349
         hapic_data.headers = processed_data
309
 
350
 
310
-    def get_processed_data(
311
-        self,
312
-        request_parameters: RequestParameters,
313
-    ) -> typing.Any:
314
-        processed_data = self.processor.process(
315
-            request_parameters.header_parameters,
316
-        )
317
-        return processed_data
351
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
352
+        return request_parameters.header_parameters
318
 
353
 
319
 
354
 
320
 class InputFormsControllerWrapper(InputControllerWrapper):
355
 class InputFormsControllerWrapper(InputControllerWrapper):
324
     ) -> None:
359
     ) -> None:
325
         hapic_data.forms = processed_data
360
         hapic_data.forms = processed_data
326
 
361
 
327
-    def get_processed_data(
328
-        self,
329
-        request_parameters: RequestParameters,
330
-    ) -> typing.Any:
331
-        processed_data = self.processor.process(
332
-            request_parameters.form_parameters,
333
-        )
334
-        return processed_data
362
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
363
+        return request_parameters.form_parameters
364
+
365
+
366
+class InputFilesControllerWrapper(InputControllerWrapper):
367
+    def update_hapic_data(
368
+        self, hapic_data: HapicData,
369
+        processed_data: typing.Any,
370
+    ) -> None:
371
+        hapic_data.files = processed_data
372
+
373
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:  # nopep8
374
+        return request_parameters.files_parameters
335
 
375
 
336
 
376
 
337
 class ExceptionHandlerControllerWrapper(ControllerWrapper):
377
 class ExceptionHandlerControllerWrapper(ControllerWrapper):

+ 13 - 2
hapic/description.py View File

27
 
27
 
28
 
28
 
29
 class InputFormsDescription(Description):
29
 class InputFormsDescription(Description):
30
-    def __init__(self, wrapper: 'ControllerWrapper') -> None:
31
-        self.wrapper = wrapper
30
+    pass
31
+
32
+
33
+class InputFilesDescription(Description):
34
+    pass
32
 
35
 
33
 
36
 
34
 class OutputBodyDescription(Description):
37
 class OutputBodyDescription(Description):
35
     pass
38
     pass
36
 
39
 
37
 
40
 
41
+class OutputFileDescription(Description):
42
+    pass
43
+
44
+
38
 class OutputHeadersDescription(Description):
45
 class OutputHeadersDescription(Description):
39
     pass
46
     pass
40
 
47
 
51
         input_body: InputBodyDescription=None,
58
         input_body: InputBodyDescription=None,
52
         input_headers: InputHeadersDescription=None,
59
         input_headers: InputHeadersDescription=None,
53
         input_forms: InputFormsDescription=None,
60
         input_forms: InputFormsDescription=None,
61
+        input_files: InputFilesDescription=None,
54
         output_body: OutputBodyDescription=None,
62
         output_body: OutputBodyDescription=None,
63
+        output_file: OutputFileDescription=None,
55
         output_headers: OutputHeadersDescription=None,
64
         output_headers: OutputHeadersDescription=None,
56
         errors: typing.List[ErrorDescription]=None,
65
         errors: typing.List[ErrorDescription]=None,
57
     ):
66
     ):
60
         self.input_body = input_body
69
         self.input_body = input_body
61
         self.input_headers = input_headers
70
         self.input_headers = input_headers
62
         self.input_forms = input_forms
71
         self.input_forms = input_forms
72
+        self.input_files = input_files
63
         self.output_body = output_body
73
         self.output_body = output_body
74
+        self.output_file = output_file
64
         self.output_headers = output_headers
75
         self.output_headers = output_headers
65
         self.errors = errors or []
76
         self.errors = errors or []

+ 32 - 1
hapic/doc.py View File

19
     app: bottle.Bottle,
19
     app: bottle.Bottle,
20
 ):
20
 ):
21
     if not app.routes:
21
     if not app.routes:
22
-        raise NoRoutesException('There is no routes in yout bottle app')
22
+        raise NoRoutesException('There is no routes in your bottle app')
23
 
23
 
24
     reference = decorated_controller.reference
24
     reference = decorated_controller.reference
25
     for route in app.routes:
25
     for route in app.routes:
71
                 }
71
                 }
72
             }
72
             }
73
 
73
 
74
+    if description.output_file:
75
+        method_operations.setdefault('produces', []).extend(
76
+            description.output_file.wrapper.output_types
77
+        )
78
+        method_operations.setdefault('responses', {})\
79
+            [int(description.output_file.wrapper.default_http_code)] = {
80
+            'description': str(description.output_file.wrapper.default_http_code),  # nopep8
81
+        }
82
+
74
     if description.errors:
83
     if description.errors:
75
         for error in description.errors:
84
         for error in description.errors:
76
             schema_class = type(error.wrapper.schema)
85
             schema_class = type(error.wrapper.schema)
106
                 'type': schema['type']
115
                 'type': schema['type']
107
             })
116
             })
108
 
117
 
118
+    if description.input_files:
119
+        method_operations.setdefault('consumes', []).append('multipart/form-data')
120
+        for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
121
+            method_operations.setdefault('parameters', []).append({
122
+                'in': 'formData',
123
+                'name': field_name,
124
+                'required': field.required,
125
+                'type': 'file',
126
+            })
127
+
109
     operations = {
128
     operations = {
110
         route.method.lower(): method_operations,
129
         route.method.lower(): method_operations,
111
     }
130
     }
126
                 # 'apispec.ext.bottle',
145
                 # 'apispec.ext.bottle',
127
                 'apispec.ext.marshmallow',
146
                 'apispec.ext.marshmallow',
128
             ],
147
             ],
148
+            schema_name_resolver_callable=generate_schema_name,
129
         )
149
         )
130
 
150
 
131
         schemas = []
151
         schemas = []
168
                 controller.description,
188
                 controller.description,
169
             )
189
             )
170
 
190
 
191
+            # TODO BS 20171114: TMP code waiting refact of doc
192
+            doc_string = controller.reference.get_doc_string()
193
+            if doc_string:
194
+                for method in operations.keys():
195
+                    operations[method]['description'] = doc_string
196
+
171
             path = Path(path=swagger_path, operations=operations)
197
             path = Path(path=swagger_path, operations=operations)
172
 
198
 
173
             if swagger_path in paths:
199
             if swagger_path in paths:
178
             spec.add_path(path)
204
             spec.add_path(path)
179
 
205
 
180
         return spec.to_dict()
206
         return spec.to_dict()
207
+
208
+
209
+# TODO BS 20171109: Must take care of already existing definition names
210
+def generate_schema_name(schema):
211
+    return schema.__name__

+ 4 - 0
hapic/exception.py View File

5
     pass
5
     pass
6
 
6
 
7
 
7
 
8
+class ConfigurationException(HapicException):
9
+    pass
10
+
11
+
8
 class WorkflowException(HapicException):
12
 class WorkflowException(HapicException):
9
     pass
13
     pass
10
 
14
 

+ 14 - 5
hapic/ext/bottle/context.py View File

5
 from http import HTTPStatus
5
 from http import HTTPStatus
6
 
6
 
7
 import bottle
7
 import bottle
8
+from multidict import MultiDict
8
 
9
 
9
 from hapic.context import ContextInterface
10
 from hapic.context import ContextInterface
10
 from hapic.exception import OutputValidationException
11
 from hapic.exception import OutputValidationException
16
 
17
 
17
 class BottleContext(ContextInterface):
18
 class BottleContext(ContextInterface):
18
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
19
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
20
+        path_parameters = dict(bottle.request.url_args)
21
+        query_parameters = MultiDict(bottle.request.query.allitems())
22
+        body_parameters = dict(bottle.request.json or {})
23
+        form_parameters = MultiDict(bottle.request.forms.allitems())
24
+        header_parameters = dict(bottle.request.headers)
25
+        files_parameters = dict(bottle.request.files)
26
+
19
         return RequestParameters(
27
         return RequestParameters(
20
-            path_parameters=bottle.request.url_args,
21
-            query_parameters=bottle.request.params, ## query?
22
-            body_parameters=bottle.request.json,
23
-            form_parameters=bottle.request.forms,
24
-            header_parameters=bottle.request.headers,
28
+            path_parameters=path_parameters,
29
+            query_parameters=query_parameters, ## query?
30
+            body_parameters=body_parameters,
31
+            form_parameters=form_parameters,
32
+            header_parameters=header_parameters,
33
+            files_parameters=files_parameters,
25
         )
34
         )
26
 
35
 
27
     def get_response(
36
     def get_response(

+ 48 - 0
hapic/hapic.py View File

16
 from hapic.decorator import InputHeadersControllerWrapper
16
 from hapic.decorator import InputHeadersControllerWrapper
17
 from hapic.decorator import InputPathControllerWrapper
17
 from hapic.decorator import InputPathControllerWrapper
18
 from hapic.decorator import InputQueryControllerWrapper
18
 from hapic.decorator import InputQueryControllerWrapper
19
+from hapic.decorator import InputFilesControllerWrapper
19
 from hapic.decorator import OutputBodyControllerWrapper
20
 from hapic.decorator import OutputBodyControllerWrapper
20
 from hapic.decorator import OutputHeadersControllerWrapper
21
 from hapic.decorator import OutputHeadersControllerWrapper
22
+from hapic.decorator import OutputFileControllerWrapper
21
 from hapic.description import InputBodyDescription
23
 from hapic.description import InputBodyDescription
22
 from hapic.description import ErrorDescription
24
 from hapic.description import ErrorDescription
23
 from hapic.description import InputFormsDescription
25
 from hapic.description import InputFormsDescription
24
 from hapic.description import InputHeadersDescription
26
 from hapic.description import InputHeadersDescription
25
 from hapic.description import InputPathDescription
27
 from hapic.description import InputPathDescription
26
 from hapic.description import InputQueryDescription
28
 from hapic.description import InputQueryDescription
29
+from hapic.description import InputFilesDescription
27
 from hapic.description import OutputBodyDescription
30
 from hapic.description import OutputBodyDescription
28
 from hapic.description import OutputHeadersDescription
31
 from hapic.description import OutputHeadersDescription
32
+from hapic.description import OutputFileDescription
29
 from hapic.doc import DocGenerator
33
 from hapic.doc import DocGenerator
30
 from hapic.processor import ProcessorInterface
34
 from hapic.processor import ProcessorInterface
31
 from hapic.processor import MarshmallowInputProcessor
35
 from hapic.processor import MarshmallowInputProcessor
36
+from hapic.processor import MarshmallowInputFilesProcessor
32
 from hapic.processor import MarshmallowOutputProcessor
37
 from hapic.processor import MarshmallowOutputProcessor
33
 
38
 
34
 
39
 
149
             return decoration.get_wrapper(func)
154
             return decoration.get_wrapper(func)
150
         return decorator
155
         return decorator
151
 
156
 
157
+    # TODO BS 20171102: Think about possibilities to validate output ?
158
+    # (with mime type, or validator)
159
+    def output_file(
160
+        self,
161
+        output_types: typing.List[str],
162
+        default_http_code: HTTPStatus = HTTPStatus.OK,
163
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
164
+        decoration = OutputFileControllerWrapper(
165
+            output_types=output_types,
166
+            default_http_code=default_http_code,
167
+        )
168
+
169
+        def decorator(func):
170
+            self._buffer.output_file = OutputFileDescription(decoration)
171
+            return decoration.get_wrapper(func)
172
+        return decorator
173
+
152
     def input_headers(
174
     def input_headers(
153
         self,
175
         self,
154
         schema: typing.Any,
176
         schema: typing.Any,
204
         context: ContextInterface = None,
226
         context: ContextInterface = None,
205
         error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
227
         error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
206
         default_http_code: HTTPStatus = HTTPStatus.OK,
228
         default_http_code: HTTPStatus = HTTPStatus.OK,
229
+        as_list: typing.List[str]=None,
207
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
230
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
208
         processor = processor or MarshmallowInputProcessor()
231
         processor = processor or MarshmallowInputProcessor()
209
         processor.schema = schema
232
         processor.schema = schema
214
             processor=processor,
237
             processor=processor,
215
             error_http_code=error_http_code,
238
             error_http_code=error_http_code,
216
             default_http_code=default_http_code,
239
             default_http_code=default_http_code,
240
+            as_list=as_list,
217
         )
241
         )
218
 
242
 
219
         def decorator(func):
243
         def decorator(func):
269
             return decoration.get_wrapper(func)
293
             return decoration.get_wrapper(func)
270
         return decorator
294
         return decorator
271
 
295
 
296
+    def input_files(
297
+        self,
298
+        schema: typing.Any,
299
+        processor: ProcessorInterface=None,
300
+        context: ContextInterface=None,
301
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
302
+        default_http_code: HTTPStatus = HTTPStatus.OK,
303
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
304
+        processor = processor or MarshmallowInputFilesProcessor()
305
+        processor.schema = schema
306
+        context = context or self._context_getter
307
+
308
+        decoration = InputFilesControllerWrapper(
309
+            context=context,
310
+            processor=processor,
311
+            error_http_code=error_http_code,
312
+            default_http_code=default_http_code,
313
+        )
314
+
315
+        def decorator(func):
316
+            self._buffer.input_files = InputFilesDescription(decoration)
317
+            return decoration.get_wrapper(func)
318
+        return decorator
319
+
272
     def handle_exception(
320
     def handle_exception(
273
         self,
321
         self,
274
         handled_exception_class: typing.Type[Exception],
322
         handled_exception_class: typing.Type[Exception],

+ 108 - 7
hapic/processor.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import typing
2
 import typing
3
 
3
 
4
-from hapic.exception import InputValidationException, OutputValidationException
4
+from multidict import MultiDict
5
+
6
+from hapic.exception import OutputValidationException
7
+from hapic.exception import ConfigurationException
5
 
8
 
6
 
9
 
7
 class RequestParameters(object):
10
 class RequestParameters(object):
8
     def __init__(
11
     def __init__(
9
         self,
12
         self,
10
-        path_parameters,
11
-        query_parameters,
12
-        body_parameters,
13
-        form_parameters,
14
-        header_parameters,
13
+        path_parameters: dict,
14
+        query_parameters: MultiDict,
15
+        body_parameters: dict,
16
+        form_parameters: MultiDict,
17
+        header_parameters: dict,
18
+        files_parameters: dict,
15
     ):
19
     ):
20
+        """
21
+        :param path_parameters: Parameters found in path, example:
22
+            (for '/users/<user_id>') '/users/42' =>{'user_id': '42'}
23
+
24
+        :param query_parameters: Parameters found in query, example:
25
+            '/foo?group_id=1&group_id=2&deleted=false' => MultiDict(
26
+                (
27
+                    ('group_id', '1'),
28
+                    ('group_id', '2'),
29
+                    ('deleted', 'false'),
30
+                )
31
+            )
32
+
33
+        :param body_parameters: Body content in dict format, example:
34
+            JSON body '{"user": {"name":"bob"}}' => {'user': {'name':'bob'}}
35
+
36
+        :param form_parameters: Form parameters, example:
37
+            <input type="text" name="name" value="bob"/> => {'name': 'bob'}
38
+
39
+        :param header_parameters: headers in dict format, example:
40
+            Connection: keep-alive
41
+            Content-Type: text/plain => {
42
+                                            'Connection': 'keep-alive',
43
+                                            'Content-Type': 'text/plain',
44
+                                        }
45
+
46
+        :param files_parameters: TODO BS 20171113: Specify type of file
47
+        storage ?
48
+        """
16
         self.path_parameters = path_parameters
49
         self.path_parameters = path_parameters
17
         self.query_parameters = query_parameters
50
         self.query_parameters = query_parameters
18
         self.body_parameters = body_parameters
51
         self.body_parameters = body_parameters
19
         self.form_parameters = form_parameters
52
         self.form_parameters = form_parameters
20
         self.header_parameters = header_parameters
53
         self.header_parameters = header_parameters
54
+        self.files_parameters = files_parameters
21
 
55
 
22
 
56
 
23
 class ProcessValidationError(object):
57
 class ProcessValidationError(object):
32
 
66
 
33
 class ProcessorInterface(object):
67
 class ProcessorInterface(object):
34
     def __init__(self):
68
     def __init__(self):
35
-        self.schema = None
69
+        self._schema = None
70
+
71
+    @property
72
+    def schema(self):
73
+        if not self._schema:
74
+            raise ConfigurationException('Schema not set for processor {}'.format(str(self)))
75
+        return self._schema
76
+
77
+    @schema.setter
78
+    def schema(self, schema):
79
+        self._schema = schema
36
 
80
 
37
     def process(self, value):
81
     def process(self, value):
38
         raise NotImplementedError
82
         raise NotImplementedError
108
             message='Validation error of input data',
152
             message='Validation error of input data',
109
             details=marshmallow_errors,
153
             details=marshmallow_errors,
110
         )
154
         )
155
+
156
+
157
+class MarshmallowInputFilesProcessor(MarshmallowInputProcessor):
158
+    def process(self, data: dict):
159
+        clean_data = self.clean_data(data)
160
+        unmarshall = self.schema.load(clean_data)
161
+        additional_errors = self._get_files_errors(unmarshall.data)
162
+
163
+        if unmarshall.errors:
164
+            raise OutputValidationException(
165
+                'Error when validate ouput: {}'.format(
166
+                    str(unmarshall.errors),
167
+                )
168
+            )
169
+
170
+        if additional_errors:
171
+            raise OutputValidationException(
172
+                'Error when validate ouput: {}'.format(
173
+                    str(additional_errors),
174
+                )
175
+            )
176
+
177
+        return unmarshall.data
178
+
179
+    def get_validation_error(self, data: dict) -> ProcessValidationError:
180
+        clean_data = self.clean_data(data)
181
+        unmarshall = self.schema.load(clean_data)
182
+        marshmallow_errors = unmarshall.errors
183
+        additional_errors = self._get_files_errors(unmarshall.data)
184
+
185
+        if marshmallow_errors:
186
+            return ProcessValidationError(
187
+                message='Validation error of input data',
188
+                details=marshmallow_errors,
189
+            )
190
+
191
+        if additional_errors:
192
+            return ProcessValidationError(
193
+                message='Validation error of input data',
194
+                details=additional_errors,
195
+            )
196
+
197
+    def _get_files_errors(self, validated_data: dict) -> typing.Dict[str, str]:
198
+        """
199
+        Additional check of data
200
+        :param validated_data: previously validated data by marshmallow schema
201
+        :return: list of error if any
202
+        """
203
+        errors = {}
204
+
205
+        for field_name, field in self.schema.fields.items():
206
+            # Actually just check if value not empty
207
+            # TODO BS 20171102: Think about case where test file content is more complicated
208
+            if field.required and (field_name not in validated_data or not validated_data[field_name]):
209
+                errors.setdefault(field_name, []).append('Missing data for required field')
210
+
211
+        return errors

+ 12 - 3
setup.py View File

11
     # TODO: marshmallow an extension too ? see #2
11
     # TODO: marshmallow an extension too ? see #2
12
     'bottle',
12
     'bottle',
13
     'marshmallow',
13
     'marshmallow',
14
-    'apispec',
14
+    'apispec==0.25.4-algoo',
15
+    'multidict'
16
+]
17
+dependency_links = [
18
+    'git+https://github.com/algoo/apispec.git@dev-algoo#egg=apispec-0.25.4-algoo'  # nopep8
15
 ]
19
 ]
16
 tests_require = [
20
 tests_require = [
17
     'pytest',
21
     'pytest',
18
 ]
22
 ]
23
+dev_require = [
24
+    'requests',
25
+]
19
 
26
 
20
 setup(
27
 setup(
21
     name='hapic',
28
     name='hapic',
23
     # Versions should comply with PEP440.  For a discussion on single-sourcing
30
     # Versions should comply with PEP440.  For a discussion on single-sourcing
24
     # the version across setup.py and the project code, see
31
     # the version across setup.py and the project code, see
25
     # https://packaging.python.org/en/latest/single_source_version.html
32
     # https://packaging.python.org/en/latest/single_source_version.html
26
-    version='0.4.2',
33
+    version='0.14',
27
 
34
 
28
     description='HTTP api input/output manager',
35
     description='HTTP api input/output manager',
29
     # long_description=long_description,
36
     # long_description=long_description,
30
     long_description='',
37
     long_description='',
31
 
38
 
32
     # The project's main homepage.
39
     # The project's main homepage.
33
-    url='http://gitlab.algoo.fr:10080/algoo/hapic.git',
40
+    url='https://github.com/algoo/hapic',
34
 
41
 
35
     # Author details
42
     # Author details
36
     author='Algoo Development Team',
43
     author='Algoo Development Team',
55
     # requirements files see:
62
     # requirements files see:
56
     # https://packaging.python.org/en/latest/requirements.html
63
     # https://packaging.python.org/en/latest/requirements.html
57
     install_requires=install_requires,
64
     install_requires=install_requires,
65
+    dependency_links=dependency_links,
58
 
66
 
59
     # List additional groups of dependencies here (e.g. development
67
     # List additional groups of dependencies here (e.g. development
60
     # dependencies). You can install these using the following syntax,
68
     # dependencies). You can install these using the following syntax,
62
     # $ pip install -e ".[test]"
70
     # $ pip install -e ".[test]"
63
     extras_require={
71
     extras_require={
64
         'test': tests_require,
72
         'test': tests_require,
73
+        'dev': dev_require,
65
     },
74
     },
66
 
75
 
67
     # If there are data files included in your packages that need to be
76
     # If there are data files included in your packages that need to be

+ 57 - 0
tests/base.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import typing
3
+from http import HTTPStatus
4
+
5
+from multidict import MultiDict
6
+
7
+from hapic.context import ContextInterface
8
+from hapic.processor import RequestParameters
9
+from hapic.processor import ProcessValidationError
10
+
2
 
11
 
3
 class Base(object):
12
 class Base(object):
4
     pass
13
     pass
14
+
15
+
16
+class MyContext(ContextInterface):
17
+    def __init__(
18
+        self,
19
+        fake_path_parameters=None,
20
+        fake_query_parameters=None,
21
+        fake_body_parameters=None,
22
+        fake_form_parameters=None,
23
+        fake_header_parameters=None,
24
+        fake_files_parameters=None,
25
+    ) -> None:
26
+        self.fake_path_parameters = fake_path_parameters or {}
27
+        self.fake_query_parameters = fake_query_parameters or MultiDict()
28
+        self.fake_body_parameters = fake_body_parameters or {}
29
+        self.fake_form_parameters = fake_form_parameters or MultiDict()
30
+        self.fake_header_parameters = fake_header_parameters or {}
31
+        self.fake_files_parameters = fake_files_parameters or {}
32
+
33
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
34
+        return RequestParameters(
35
+            path_parameters=self.fake_path_parameters,
36
+            query_parameters=self.fake_query_parameters,
37
+            body_parameters=self.fake_body_parameters,
38
+            form_parameters=self.fake_form_parameters,
39
+            header_parameters=self.fake_header_parameters,
40
+            files_parameters=self.fake_files_parameters,
41
+        )
42
+
43
+    def get_response(
44
+        self,
45
+        response: dict,
46
+        http_code: int,
47
+    ) -> typing.Any:
48
+        return {
49
+            'original_response': response,
50
+            'http_code': http_code,
51
+        }
52
+
53
+    def get_validation_error_response(
54
+        self,
55
+        error: ProcessValidationError,
56
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
57
+    ) -> typing.Any:
58
+        return {
59
+            'original_error': error,
60
+            'http_code': http_code,
61
+        }

+ 1 - 0
tests/func/__init__.py View File

1
+# coding: utf-8

+ 149 - 0
tests/func/test_doc.py View File

1
+# coding: utf-8
2
+import marshmallow
3
+import bottle
4
+
5
+from hapic import Hapic
6
+from tests.base import Base
7
+from tests.base import MyContext
8
+
9
+
10
+class TestDocGeneration(Base):
11
+    def test_func__input_files_doc__ok__one_file(self):
12
+        hapic = Hapic()
13
+        hapic.set_context(MyContext())
14
+        app = bottle.Bottle()
15
+
16
+        class MySchema(marshmallow.Schema):
17
+            file_abc = marshmallow.fields.Raw(required=True)
18
+
19
+        @hapic.with_api_doc()
20
+        @hapic.input_files(MySchema())
21
+        def my_controller(hapic_data=None):
22
+            assert hapic_data
23
+            assert hapic_data.files
24
+
25
+        app.route('/upload', method='POST', callback=my_controller)
26
+        doc = hapic.generate_doc(app)
27
+
28
+        assert doc
29
+        assert '/upload' in doc['paths']
30
+        assert 'consumes' in doc['paths']['/upload']['post']
31
+        assert 'multipart/form-data' in doc['paths']['/upload']['post']['consumes']  # nopep8
32
+        assert 'parameters' in doc['paths']['/upload']['post']
33
+        assert {
34
+                   'name': 'file_abc',
35
+                   'required': True,
36
+                   'in': 'formData',
37
+                   'type': 'file',
38
+               } in doc['paths']['/upload']['post']['parameters']
39
+
40
+    def test_func__input_files_doc__ok__two_file(self):
41
+        hapic = Hapic()
42
+        hapic.set_context(MyContext())
43
+        app = bottle.Bottle()
44
+
45
+        class MySchema(marshmallow.Schema):
46
+            file_abc = marshmallow.fields.Raw(required=True)
47
+            file_def = marshmallow.fields.Raw(required=False)
48
+
49
+        @hapic.with_api_doc()
50
+        @hapic.input_files(MySchema())
51
+        def my_controller(hapic_data=None):
52
+            assert hapic_data
53
+            assert hapic_data.files
54
+
55
+        app.route('/upload', method='POST', callback=my_controller)
56
+        doc = hapic.generate_doc(app)
57
+
58
+        assert doc
59
+        assert '/upload' in doc['paths']
60
+        assert 'consumes' in doc['paths']['/upload']['post']
61
+        assert 'multipart/form-data' in doc['paths']['/upload']['post']['consumes']  # nopep8
62
+        assert 'parameters' in doc['paths']['/upload']['post']
63
+        assert {
64
+                   'name': 'file_abc',
65
+                   'required': True,
66
+                   'in': 'formData',
67
+                   'type': 'file',
68
+               } in doc['paths']['/upload']['post']['parameters']
69
+        assert {
70
+                   'name': 'file_def',
71
+                   'required': False,
72
+                   'in': 'formData',
73
+                   'type': 'file',
74
+               } in doc['paths']['/upload']['post']['parameters']
75
+
76
+    def test_func__output_file_doc__ok__nominal_case(self):
77
+        hapic = Hapic()
78
+        hapic.set_context(MyContext())
79
+        app = bottle.Bottle()
80
+
81
+        @hapic.with_api_doc()
82
+        @hapic.output_file(['image/jpeg'])
83
+        def my_controller():
84
+            return b'101010100101'
85
+
86
+        app.route('/avatar', method='GET', callback=my_controller)
87
+        doc = hapic.generate_doc(app)
88
+
89
+        assert doc
90
+        assert '/avatar' in doc['paths']
91
+        assert 'produces' in doc['paths']['/avatar']['get']
92
+        assert 'image/jpeg' in doc['paths']['/avatar']['get']['produces']
93
+        assert 200 in doc['paths']['/avatar']['get']['responses']
94
+
95
+    def test_func__input_files_doc__ok__one_file_and_text(self):
96
+        hapic = Hapic()
97
+        hapic.set_context(MyContext())
98
+        app = bottle.Bottle()
99
+
100
+        class MySchema(marshmallow.Schema):
101
+            name = marshmallow.fields.String(required=True)
102
+
103
+        class MyFilesSchema(marshmallow.Schema):
104
+            file_abc = marshmallow.fields.Raw(required=True)
105
+
106
+        @hapic.with_api_doc()
107
+        @hapic.input_files(MyFilesSchema())
108
+        @hapic.input_body(MySchema())
109
+        def my_controller(hapic_data=None):
110
+            assert hapic_data
111
+            assert hapic_data.files
112
+
113
+        app.route('/upload', method='POST', callback=my_controller)
114
+        doc = hapic.generate_doc(app)
115
+
116
+        assert doc
117
+        assert '/upload' in doc['paths']
118
+        assert 'consumes' in doc['paths']['/upload']['post']
119
+        assert 'multipart/form-data' in doc['paths']['/upload']['post']['consumes']  # nopep8
120
+        assert 'parameters' in doc['paths']['/upload']['post']
121
+        assert {
122
+                   'name': 'file_abc',
123
+                   'required': True,
124
+                   'in': 'formData',
125
+                   'type': 'file',
126
+               } in doc['paths']['/upload']['post']['parameters']
127
+
128
+    def test_func__docstring__ok__simple_case(self):
129
+        hapic = Hapic()
130
+        hapic.set_context(MyContext())
131
+        app = bottle.Bottle()
132
+
133
+        # TODO BS 20171113: Make this test non-bottle
134
+        @hapic.with_api_doc()
135
+        def my_controller(hapic_data=None):
136
+            """
137
+            Hello doc
138
+            """
139
+            assert hapic_data
140
+            assert hapic_data.files
141
+
142
+        app.route('/upload', method='POST', callback=my_controller)
143
+        doc = hapic.generate_doc(app)
144
+
145
+        assert doc.get('paths')
146
+        assert '/upload' in doc['paths']
147
+        assert 'post' in doc['paths']['/upload']
148
+        assert 'description' in doc['paths']['/upload']['post']
149
+        assert 'Hello doc' == doc['paths']['/upload']['post']['description']

+ 73 - 0
tests/func/test_marshmallow_decoration.py View File

1
+# coding: utf-8
2
+from http import HTTPStatus
3
+
4
+import marshmallow
5
+
6
+from hapic import Hapic
7
+from tests.base import Base
8
+from tests.base import MyContext
9
+
10
+
11
+class TestMarshmallowDecoration(Base):
12
+    def test_unit__input_files__ok__file_is_present(self):
13
+        hapic = Hapic()
14
+        hapic.set_context(MyContext(fake_files_parameters={
15
+            'file_abc': '10101010101',
16
+        }))
17
+
18
+        class MySchema(marshmallow.Schema):
19
+            file_abc = marshmallow.fields.Raw(required=True)
20
+
21
+        @hapic.with_api_doc()
22
+        @hapic.input_files(MySchema())
23
+        def my_controller(hapic_data=None):
24
+            assert hapic_data
25
+            assert hapic_data.files
26
+            return 'OK'
27
+
28
+        result = my_controller()
29
+        assert 'OK' == result
30
+
31
+    def test_unit__input_files__ok__file_is_not_present(self):
32
+        hapic = Hapic()
33
+        hapic.set_context(MyContext(fake_files_parameters={
34
+            # No file here
35
+        }))
36
+
37
+        class MySchema(marshmallow.Schema):
38
+            file_abc = marshmallow.fields.Raw(required=True)
39
+
40
+        @hapic.with_api_doc()
41
+        @hapic.input_files(MySchema())
42
+        def my_controller(hapic_data=None):
43
+            assert hapic_data
44
+            assert hapic_data.files
45
+            return 'OK'
46
+
47
+        result = my_controller()
48
+        assert 'http_code' in result
49
+        assert HTTPStatus.BAD_REQUEST == result['http_code']
50
+        assert {
51
+                   'file_abc': ['Missing data for required field.']
52
+               } == result['original_error'].details
53
+
54
+    def test_unit__input_files__ok__file_is_empty_string(self):
55
+        hapic = Hapic()
56
+        hapic.set_context(MyContext(fake_files_parameters={
57
+            'file_abc': '',
58
+        }))
59
+
60
+        class MySchema(marshmallow.Schema):
61
+            file_abc = marshmallow.fields.Raw(required=True)
62
+
63
+        @hapic.with_api_doc()
64
+        @hapic.input_files(MySchema())
65
+        def my_controller(hapic_data=None):
66
+            assert hapic_data
67
+            assert hapic_data.files
68
+            return 'OK'
69
+
70
+        result = my_controller()
71
+        assert 'http_code' in result
72
+        assert HTTPStatus.BAD_REQUEST == result['http_code']
73
+        assert {'file_abc': ['Missing data for required field']} == result['original_error'].details

+ 77 - 37
tests/unit/test_decorator.py View File

3
 from http import HTTPStatus
3
 from http import HTTPStatus
4
 
4
 
5
 import marshmallow
5
 import marshmallow
6
+from multidict import MultiDict
6
 
7
 
7
-from hapic.context import ContextInterface
8
 from hapic.data import HapicData
8
 from hapic.data import HapicData
9
-from hapic.decorator import InputOutputControllerWrapper
10
 from hapic.decorator import ExceptionHandlerControllerWrapper
9
 from hapic.decorator import ExceptionHandlerControllerWrapper
10
+from hapic.decorator import InputQueryControllerWrapper
11
 from hapic.decorator import InputControllerWrapper
11
 from hapic.decorator import InputControllerWrapper
12
+from hapic.decorator import InputOutputControllerWrapper
12
 from hapic.decorator import OutputControllerWrapper
13
 from hapic.decorator import OutputControllerWrapper
13
 from hapic.hapic import ErrorResponseSchema
14
 from hapic.hapic import ErrorResponseSchema
14
-from hapic.processor import RequestParameters
15
 from hapic.processor import MarshmallowOutputProcessor
15
 from hapic.processor import MarshmallowOutputProcessor
16
 from hapic.processor import ProcessValidationError
16
 from hapic.processor import ProcessValidationError
17
 from hapic.processor import ProcessorInterface
17
 from hapic.processor import ProcessorInterface
18
+from hapic.processor import RequestParameters
18
 from tests.base import Base
19
 from tests.base import Base
20
+from tests.base import MyContext
19
 
21
 
20
 
22
 
21
-class MyContext(ContextInterface):
22
-    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
23
-        return RequestParameters(
24
-            path_parameters={'fake': args},
25
-            query_parameters={},
26
-            body_parameters={},
27
-            form_parameters={},
28
-            header_parameters={},
29
-        )
30
-
31
-    def get_response(
32
-        self,
33
-        response: dict,
34
-        http_code: int,
35
-    ) -> typing.Any:
36
-        return {
37
-            'original_response': response,
38
-            'http_code': http_code,
39
-        }
23
+class MyProcessor(ProcessorInterface):
24
+    def process(self, value):
25
+        return value + 1
40
 
26
 
41
-    def get_validation_error_response(
27
+    def get_validation_error(
42
         self,
28
         self,
43
-        error: ProcessValidationError,
44
-        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
45
-    ) -> typing.Any:
46
-        return {
47
-            'original_error': error,
48
-            'http_code': http_code,
49
-        }
29
+        request_context: RequestParameters,
30
+    ) -> ProcessValidationError:
31
+        return ProcessValidationError(
32
+            details={
33
+                'original_request_context': request_context,
34
+            },
35
+            message='ERROR',
36
+        )
50
 
37
 
51
 
38
 
52
-class MyProcessor(ProcessorInterface):
39
+class MySimpleProcessor(ProcessorInterface):
53
     def process(self, value):
40
     def process(self, value):
54
-        return value + 1
41
+        return value
55
 
42
 
56
     def get_validation_error(
43
     def get_validation_error(
57
         self,
44
         self,
82
         return response * 2
69
         return response * 2
83
 
70
 
84
 
71
 
85
-class MyInputControllerWrapper(InputControllerWrapper):
72
+class MyInputQueryControllerWrapper(InputControllerWrapper):
86
     def get_processed_data(
73
     def get_processed_data(
87
         self,
74
         self,
88
         request_parameters: RequestParameters,
75
         request_parameters: RequestParameters,
89
     ) -> typing.Any:
76
     ) -> typing.Any:
90
-        return {'we_are_testing': request_parameters.path_parameters}
77
+        return request_parameters.query_parameters
91
 
78
 
92
     def update_hapic_data(
79
     def update_hapic_data(
93
         self,
80
         self,
146
 
133
 
147
 class TestInputControllerWrapper(Base):
134
 class TestInputControllerWrapper(Base):
148
     def test_unit__input_data_wrapping__ok__nominal_case(self):
135
     def test_unit__input_data_wrapping__ok__nominal_case(self):
149
-        context = MyContext()
136
+        context = MyContext(fake_query_parameters=MultiDict(
137
+            (
138
+                ('foo', 'bar',),
139
+            )
140
+        ))
150
         processor = MyProcessor()
141
         processor = MyProcessor()
151
-        wrapper = MyInputControllerWrapper(context, processor)
142
+        wrapper = MyInputQueryControllerWrapper(context, processor)
152
 
143
 
153
         @wrapper.get_wrapper
144
         @wrapper.get_wrapper
154
         def func(foo, hapic_data=None):
145
         def func(foo, hapic_data=None):
155
             assert hapic_data
146
             assert hapic_data
156
             assert isinstance(hapic_data, HapicData)
147
             assert isinstance(hapic_data, HapicData)
157
             # see MyControllerWrapper#before_wrapped_func
148
             # see MyControllerWrapper#before_wrapped_func
158
-            assert hapic_data.query == {'we_are_testing': {'fake': (42,)}}
149
+            assert hapic_data.query == {'foo': 'bar'}
159
             return foo
150
             return foo
160
 
151
 
161
         result = func(42)
152
         result = func(42)
162
         assert result == 42
153
         assert result == 42
163
 
154
 
155
+    def test_unit__multi_query_param_values__ok__use_as_list(self):
156
+        context = MyContext(fake_query_parameters=MultiDict(
157
+            (
158
+                ('user_id', 'abc'),
159
+                ('user_id', 'def'),
160
+            ),
161
+        ))
162
+        processor = MySimpleProcessor()
163
+        wrapper = InputQueryControllerWrapper(
164
+            context,
165
+            processor,
166
+            as_list=['user_id'],
167
+        )
168
+
169
+        @wrapper.get_wrapper
170
+        def func(hapic_data=None):
171
+            assert hapic_data
172
+            assert isinstance(hapic_data, HapicData)
173
+            # see MyControllerWrapper#before_wrapped_func
174
+            assert ['abc', 'def'] == hapic_data.query.get('user_id')
175
+            return hapic_data.query.get('user_id')
176
+
177
+        result = func()
178
+        assert result == ['abc', 'def']
179
+
180
+    def test_unit__multi_query_param_values__ok__without_as_list(self):
181
+        context = MyContext(fake_query_parameters=MultiDict(
182
+            (
183
+                ('user_id', 'abc'),
184
+                ('user_id', 'def'),
185
+            ),
186
+        ))
187
+        processor = MySimpleProcessor()
188
+        wrapper = InputQueryControllerWrapper(
189
+            context,
190
+            processor,
191
+        )
192
+
193
+        @wrapper.get_wrapper
194
+        def func(hapic_data=None):
195
+            assert hapic_data
196
+            assert isinstance(hapic_data, HapicData)
197
+            # see MyControllerWrapper#before_wrapped_func
198
+            assert 'abc' == hapic_data.query.get('user_id')
199
+            return hapic_data.query.get('user_id')
200
+
201
+        result = func()
202
+        assert result == 'abc'
203
+
164
 
204
 
165
 class TestOutputControllerWrapper(Base):
205
 class TestOutputControllerWrapper(Base):
166
     def test_unit__output_data_wrapping__ok__nominal_case(self):
206
     def test_unit__output_data_wrapping__ok__nominal_case(self):