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

@@ -0,0 +1,20 @@
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,3 +31,7 @@ class HelloJsonSchema(marshmallow.Schema):
31 31
         required=True,
32 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,7 +9,7 @@ from beaker.middleware import SessionMiddleware
9 9
 
10 10
 import hapic
11 11
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
12
-    ErrorResponseSchema, HelloQuerySchema
12
+    ErrorResponseSchema, HelloQuerySchema, HelloFileSchema
13 13
 from hapic.data import HapicData
14 14
 
15 15
 # hapic.global_exception_handler(UnAuthExc, StandardErrorSchema)
@@ -96,10 +96,17 @@ class Controllers(object):
96 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 105
     def bind(self, app):
100 106
         app.route('/hello/<name>', callback=self.hello)
101 107
         app.route('/hello/<name>', callback=self.hello2, method='POST')
102 108
         app.route('/hello3/<name>', callback=self.hello3)
109
+        app.route('/hellofile', callback=self.hellofile)
103 110
 
104 111
 app = bottle.Bottle()
105 112
 

+ 2 - 0
hapic/__init__.py View File

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

+ 22 - 0
hapic/buffer.py View File

@@ -7,7 +7,9 @@ from hapic.description import InputQueryDescription
7 7
 from hapic.description import InputBodyDescription
8 8
 from hapic.description import InputHeadersDescription
9 9
 from hapic.description import InputFormsDescription
10
+from hapic.description import InputFilesDescription
10 11
 from hapic.description import OutputBodyDescription
12
+from hapic.description import OutputFileDescription
11 13
 from hapic.description import OutputHeadersDescription
12 14
 from hapic.description import ErrorDescription
13 15
 from hapic.exception import AlreadyDecoratedException
@@ -74,6 +76,16 @@ class DecorationBuffer(object):
74 76
         self._description.input_forms = description
75 77
 
76 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 89
     def output_body(self) -> OutputBodyDescription:
78 90
         return self._description.output_body
79 91
 
@@ -84,6 +96,16 @@ class DecorationBuffer(object):
84 96
         self._description.output_body = description
85 97
 
86 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 109
     def output_headers(self) -> OutputHeadersDescription:
88 110
         return self._description.output_headers
89 111
 

+ 1 - 0
hapic/data.py View File

@@ -8,3 +8,4 @@ class HapicData(object):
8 8
         self.query = {}
9 9
         self.headers = {}
10 10
         self.forms = {}
11
+        self.files = {}

+ 83 - 43
hapic/decorator.py View File

@@ -6,6 +6,7 @@ from http import HTTPStatus
6 6
 # TODO BS 20171010: bottle specific !  # see #5
7 7
 import marshmallow
8 8
 from bottle import HTTPResponse
9
+from multidict import MultiDict
9 10
 
10 11
 from hapic.data import HapicData
11 12
 from hapic.description import ControllerDescription
@@ -44,6 +45,15 @@ class ControllerReference(object):
44 45
         self.wrapped = wrapped
45 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 58
 class ControllerWrapper(object):
49 59
     def before_wrapped_func(
@@ -150,6 +160,11 @@ class InputControllerWrapper(InputOutputControllerWrapper):
150 160
         self,
151 161
         request_parameters: RequestParameters,
152 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 168
         raise NotImplementedError()
154 169
 
155 170
     def update_hapic_data(
@@ -163,9 +178,8 @@ class InputControllerWrapper(InputOutputControllerWrapper):
163 178
         self,
164 179
         request_parameters: RequestParameters,
165 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 183
         error_response = self.context.get_validation_error_response(
170 184
             error,
171 185
             http_code=self.error_http_code,
@@ -249,6 +263,16 @@ class OutputHeadersControllerWrapper(OutputControllerWrapper):
249 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 276
 class InputPathControllerWrapper(InputControllerWrapper):
253 277
     def update_hapic_data(
254 278
         self, hapic_data: HapicData,
@@ -256,31 +280,54 @@ class InputPathControllerWrapper(InputControllerWrapper):
256 280
     ) -> None:
257 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 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 304
     def update_hapic_data(
271 305
         self, hapic_data: HapicData,
272 306
         processed_data: typing.Any,
273 307
     ) -> None:
274 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 333
 class InputBodyControllerWrapper(InputControllerWrapper):
@@ -290,14 +337,8 @@ class InputBodyControllerWrapper(InputControllerWrapper):
290 337
     ) -> None:
291 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 344
 class InputHeadersControllerWrapper(InputControllerWrapper):
@@ -307,14 +348,8 @@ class InputHeadersControllerWrapper(InputControllerWrapper):
307 348
     ) -> None:
308 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 355
 class InputFormsControllerWrapper(InputControllerWrapper):
@@ -324,14 +359,19 @@ class InputFormsControllerWrapper(InputControllerWrapper):
324 359
     ) -> None:
325 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 377
 class ExceptionHandlerControllerWrapper(ControllerWrapper):

+ 13 - 2
hapic/description.py View File

@@ -27,14 +27,21 @@ class InputHeadersDescription(Description):
27 27
 
28 28
 
29 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 37
 class OutputBodyDescription(Description):
35 38
     pass
36 39
 
37 40
 
41
+class OutputFileDescription(Description):
42
+    pass
43
+
44
+
38 45
 class OutputHeadersDescription(Description):
39 46
     pass
40 47
 
@@ -51,7 +58,9 @@ class ControllerDescription(object):
51 58
         input_body: InputBodyDescription=None,
52 59
         input_headers: InputHeadersDescription=None,
53 60
         input_forms: InputFormsDescription=None,
61
+        input_files: InputFilesDescription=None,
54 62
         output_body: OutputBodyDescription=None,
63
+        output_file: OutputFileDescription=None,
55 64
         output_headers: OutputHeadersDescription=None,
56 65
         errors: typing.List[ErrorDescription]=None,
57 66
     ):
@@ -60,6 +69,8 @@ class ControllerDescription(object):
60 69
         self.input_body = input_body
61 70
         self.input_headers = input_headers
62 71
         self.input_forms = input_forms
72
+        self.input_files = input_files
63 73
         self.output_body = output_body
74
+        self.output_file = output_file
64 75
         self.output_headers = output_headers
65 76
         self.errors = errors or []

+ 32 - 1
hapic/doc.py View File

@@ -19,7 +19,7 @@ def find_bottle_route(
19 19
     app: bottle.Bottle,
20 20
 ):
21 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 24
     reference = decorated_controller.reference
25 25
     for route in app.routes:
@@ -71,6 +71,15 @@ def bottle_generate_operations(
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 83
     if description.errors:
75 84
         for error in description.errors:
76 85
             schema_class = type(error.wrapper.schema)
@@ -106,6 +115,16 @@ def bottle_generate_operations(
106 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 128
     operations = {
110 129
         route.method.lower(): method_operations,
111 130
     }
@@ -126,6 +145,7 @@ class DocGenerator(object):
126 145
                 # 'apispec.ext.bottle',
127 146
                 'apispec.ext.marshmallow',
128 147
             ],
148
+            schema_name_resolver_callable=generate_schema_name,
129 149
         )
130 150
 
131 151
         schemas = []
@@ -168,6 +188,12 @@ class DocGenerator(object):
168 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 197
             path = Path(path=swagger_path, operations=operations)
172 198
 
173 199
             if swagger_path in paths:
@@ -178,3 +204,8 @@ class DocGenerator(object):
178 204
             spec.add_path(path)
179 205
 
180 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,6 +5,10 @@ class HapicException(Exception):
5 5
     pass
6 6
 
7 7
 
8
+class ConfigurationException(HapicException):
9
+    pass
10
+
11
+
8 12
 class WorkflowException(HapicException):
9 13
     pass
10 14
 

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

@@ -5,6 +5,7 @@ import typing
5 5
 from http import HTTPStatus
6 6
 
7 7
 import bottle
8
+from multidict import MultiDict
8 9
 
9 10
 from hapic.context import ContextInterface
10 11
 from hapic.exception import OutputValidationException
@@ -16,12 +17,20 @@ BOTTLE_RE_PATH_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
16 17
 
17 18
 class BottleContext(ContextInterface):
18 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 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 36
     def get_response(

+ 48 - 0
hapic/hapic.py View File

@@ -16,19 +16,24 @@ from hapic.decorator import InputBodyControllerWrapper
16 16
 from hapic.decorator import InputHeadersControllerWrapper
17 17
 from hapic.decorator import InputPathControllerWrapper
18 18
 from hapic.decorator import InputQueryControllerWrapper
19
+from hapic.decorator import InputFilesControllerWrapper
19 20
 from hapic.decorator import OutputBodyControllerWrapper
20 21
 from hapic.decorator import OutputHeadersControllerWrapper
22
+from hapic.decorator import OutputFileControllerWrapper
21 23
 from hapic.description import InputBodyDescription
22 24
 from hapic.description import ErrorDescription
23 25
 from hapic.description import InputFormsDescription
24 26
 from hapic.description import InputHeadersDescription
25 27
 from hapic.description import InputPathDescription
26 28
 from hapic.description import InputQueryDescription
29
+from hapic.description import InputFilesDescription
27 30
 from hapic.description import OutputBodyDescription
28 31
 from hapic.description import OutputHeadersDescription
32
+from hapic.description import OutputFileDescription
29 33
 from hapic.doc import DocGenerator
30 34
 from hapic.processor import ProcessorInterface
31 35
 from hapic.processor import MarshmallowInputProcessor
36
+from hapic.processor import MarshmallowInputFilesProcessor
32 37
 from hapic.processor import MarshmallowOutputProcessor
33 38
 
34 39
 
@@ -149,6 +154,23 @@ class Hapic(object):
149 154
             return decoration.get_wrapper(func)
150 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 174
     def input_headers(
153 175
         self,
154 176
         schema: typing.Any,
@@ -204,6 +226,7 @@ class Hapic(object):
204 226
         context: ContextInterface = None,
205 227
         error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
206 228
         default_http_code: HTTPStatus = HTTPStatus.OK,
229
+        as_list: typing.List[str]=None,
207 230
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
208 231
         processor = processor or MarshmallowInputProcessor()
209 232
         processor.schema = schema
@@ -214,6 +237,7 @@ class Hapic(object):
214 237
             processor=processor,
215 238
             error_http_code=error_http_code,
216 239
             default_http_code=default_http_code,
240
+            as_list=as_list,
217 241
         )
218 242
 
219 243
         def decorator(func):
@@ -269,6 +293,30 @@ class Hapic(object):
269 293
             return decoration.get_wrapper(func)
270 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 320
     def handle_exception(
273 321
         self,
274 322
         handled_exception_class: typing.Type[Exception],

+ 108 - 7
hapic/processor.py View File

@@ -1,23 +1,57 @@
1 1
 # -*- coding: utf-8 -*-
2 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 10
 class RequestParameters(object):
8 11
     def __init__(
9 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 49
         self.path_parameters = path_parameters
17 50
         self.query_parameters = query_parameters
18 51
         self.body_parameters = body_parameters
19 52
         self.form_parameters = form_parameters
20 53
         self.header_parameters = header_parameters
54
+        self.files_parameters = files_parameters
21 55
 
22 56
 
23 57
 class ProcessValidationError(object):
@@ -32,7 +66,17 @@ class ProcessValidationError(object):
32 66
 
33 67
 class ProcessorInterface(object):
34 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 81
     def process(self, value):
38 82
         raise NotImplementedError
@@ -108,3 +152,60 @@ class MarshmallowInputProcessor(InputProcessor):
108 152
             message='Validation error of input data',
109 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,11 +11,18 @@ install_requires = [
11 11
     # TODO: marshmallow an extension too ? see #2
12 12
     'bottle',
13 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 20
 tests_require = [
17 21
     'pytest',
18 22
 ]
23
+dev_require = [
24
+    'requests',
25
+]
19 26
 
20 27
 setup(
21 28
     name='hapic',
@@ -23,14 +30,14 @@ setup(
23 30
     # Versions should comply with PEP440.  For a discussion on single-sourcing
24 31
     # the version across setup.py and the project code, see
25 32
     # https://packaging.python.org/en/latest/single_source_version.html
26
-    version='0.4.2',
33
+    version='0.14',
27 34
 
28 35
     description='HTTP api input/output manager',
29 36
     # long_description=long_description,
30 37
     long_description='',
31 38
 
32 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 42
     # Author details
36 43
     author='Algoo Development Team',
@@ -55,6 +62,7 @@ setup(
55 62
     # requirements files see:
56 63
     # https://packaging.python.org/en/latest/requirements.html
57 64
     install_requires=install_requires,
65
+    dependency_links=dependency_links,
58 66
 
59 67
     # List additional groups of dependencies here (e.g. development
60 68
     # dependencies). You can install these using the following syntax,
@@ -62,6 +70,7 @@ setup(
62 70
     # $ pip install -e ".[test]"
63 71
     extras_require={
64 72
         'test': tests_require,
73
+        'dev': dev_require,
65 74
     },
66 75
 
67 76
     # If there are data files included in your packages that need to be

+ 57 - 0
tests/base.py View File

@@ -1,4 +1,61 @@
1 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 12
 class Base(object):
4 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

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

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

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

@@ -0,0 +1,73 @@
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,55 +3,42 @@ import typing
3 3
 from http import HTTPStatus
4 4
 
5 5
 import marshmallow
6
+from multidict import MultiDict
6 7
 
7
-from hapic.context import ContextInterface
8 8
 from hapic.data import HapicData
9
-from hapic.decorator import InputOutputControllerWrapper
10 9
 from hapic.decorator import ExceptionHandlerControllerWrapper
10
+from hapic.decorator import InputQueryControllerWrapper
11 11
 from hapic.decorator import InputControllerWrapper
12
+from hapic.decorator import InputOutputControllerWrapper
12 13
 from hapic.decorator import OutputControllerWrapper
13 14
 from hapic.hapic import ErrorResponseSchema
14
-from hapic.processor import RequestParameters
15 15
 from hapic.processor import MarshmallowOutputProcessor
16 16
 from hapic.processor import ProcessValidationError
17 17
 from hapic.processor import ProcessorInterface
18
+from hapic.processor import RequestParameters
18 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 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 40
     def process(self, value):
54
-        return value + 1
41
+        return value
55 42
 
56 43
     def get_validation_error(
57 44
         self,
@@ -82,12 +69,12 @@ class MyControllerWrapper(InputOutputControllerWrapper):
82 69
         return response * 2
83 70
 
84 71
 
85
-class MyInputControllerWrapper(InputControllerWrapper):
72
+class MyInputQueryControllerWrapper(InputControllerWrapper):
86 73
     def get_processed_data(
87 74
         self,
88 75
         request_parameters: RequestParameters,
89 76
     ) -> typing.Any:
90
-        return {'we_are_testing': request_parameters.path_parameters}
77
+        return request_parameters.query_parameters
91 78
 
92 79
     def update_hapic_data(
93 80
         self,
@@ -146,21 +133,74 @@ class TestControllerWrapper(Base):
146 133
 
147 134
 class TestInputControllerWrapper(Base):
148 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 141
         processor = MyProcessor()
151
-        wrapper = MyInputControllerWrapper(context, processor)
142
+        wrapper = MyInputQueryControllerWrapper(context, processor)
152 143
 
153 144
         @wrapper.get_wrapper
154 145
         def func(foo, hapic_data=None):
155 146
             assert hapic_data
156 147
             assert isinstance(hapic_data, HapicData)
157 148
             # see MyControllerWrapper#before_wrapped_func
158
-            assert hapic_data.query == {'we_are_testing': {'fake': (42,)}}
149
+            assert hapic_data.query == {'foo': 'bar'}
159 150
             return foo
160 151
 
161 152
         result = func(42)
162 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 205
 class TestOutputControllerWrapper(Base):
166 206
     def test_unit__output_data_wrapping__ok__nominal_case(self):