Browse Source

Add input_files and output_file

Bastien Sevajol 6 years ago
parent
commit
704925590e

+ 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 = {}

+ 38 - 43
hapic/decorator.py View File

@@ -150,6 +150,11 @@ class InputControllerWrapper(InputOutputControllerWrapper):
150 150
         self,
151 151
         request_parameters: RequestParameters,
152 152
     ) -> typing.Any:
153
+        parameters_data = self.get_parameters_data(request_parameters)
154
+        processed_data = self.processor.process(parameters_data)
155
+        return processed_data
156
+
157
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
153 158
         raise NotImplementedError()
154 159
 
155 160
     def update_hapic_data(
@@ -163,9 +168,8 @@ class InputControllerWrapper(InputOutputControllerWrapper):
163 168
         self,
164 169
         request_parameters: RequestParameters,
165 170
     ) -> typing.Any:
166
-        error = self.processor.get_validation_error(
167
-            request_parameters.body_parameters,
168
-        )
171
+        parameters_data = self.get_parameters_data(request_parameters)
172
+        error = self.processor.get_validation_error(parameters_data)
169 173
         error_response = self.context.get_validation_error_response(
170 174
             error,
171 175
             http_code=self.error_http_code,
@@ -249,6 +253,16 @@ class OutputHeadersControllerWrapper(OutputControllerWrapper):
249 253
     pass
250 254
 
251 255
 
256
+class OutputFileControllerWrapper(ControllerWrapper):
257
+    def __init__(
258
+        self,
259
+        output_type: str,
260
+        default_http_code: HTTPStatus=HTTPStatus.OK,
261
+    ) -> None:
262
+        self.output_type = output_type
263
+        self.default_http_code = default_http_code
264
+
265
+
252 266
 class InputPathControllerWrapper(InputControllerWrapper):
253 267
     def update_hapic_data(
254 268
         self, hapic_data: HapicData,
@@ -256,14 +270,8 @@ class InputPathControllerWrapper(InputControllerWrapper):
256 270
     ) -> None:
257 271
         hapic_data.path = processed_data
258 272
 
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
273
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
274
+        return request_parameters.path_parameters
267 275
 
268 276
 
269 277
 class InputQueryControllerWrapper(InputControllerWrapper):
@@ -273,14 +281,8 @@ class InputQueryControllerWrapper(InputControllerWrapper):
273 281
     ) -> None:
274 282
         hapic_data.query = processed_data
275 283
 
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
284
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
285
+        return request_parameters.query_parameters
284 286
 
285 287
 
286 288
 class InputBodyControllerWrapper(InputControllerWrapper):
@@ -290,14 +292,8 @@ class InputBodyControllerWrapper(InputControllerWrapper):
290 292
     ) -> None:
291 293
         hapic_data.body = processed_data
292 294
 
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
295
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
296
+        return request_parameters.body_parameters
301 297
 
302 298
 
303 299
 class InputHeadersControllerWrapper(InputControllerWrapper):
@@ -307,14 +303,8 @@ class InputHeadersControllerWrapper(InputControllerWrapper):
307 303
     ) -> None:
308 304
         hapic_data.headers = processed_data
309 305
 
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
306
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
307
+        return request_parameters.header_parameters
318 308
 
319 309
 
320 310
 class InputFormsControllerWrapper(InputControllerWrapper):
@@ -324,14 +314,19 @@ class InputFormsControllerWrapper(InputControllerWrapper):
324 314
     ) -> None:
325 315
         hapic_data.forms = processed_data
326 316
 
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
317
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
318
+        return request_parameters.form_parameters
319
+
320
+
321
+class InputFilesControllerWrapper(InputControllerWrapper):
322
+    def update_hapic_data(
323
+        self, hapic_data: HapicData,
324
+        processed_data: typing.Any,
325
+    ) -> None:
326
+        hapic_data.files = processed_data
327
+
328
+    def get_parameters_data(self, request_parameters: RequestParameters) -> dict:
329
+        return request_parameters.files_parameters
335 330
 
336 331
 
337 332
 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 []

+ 20 - 1
hapic/doc.py View File

@@ -22,7 +22,7 @@ def find_bottle_route(
22 22
     app: bottle.Bottle,
23 23
 ):
24 24
     if not app.routes:
25
-        raise NoRoutesException('There is no routes in yout bottle app')
25
+        raise NoRoutesException('There is no routes in your bottle app')
26 26
 
27 27
     reference = decorated_controller.reference
28 28
     for route in app.routes:
@@ -74,6 +74,15 @@ def bottle_generate_operations(
74 74
                 }
75 75
             }
76 76
 
77
+    if description.output_file:
78
+        method_operations.setdefault('produce', []).append(
79
+            description.output_file.wrapper.output_type
80
+        )
81
+        method_operations.setdefault('responses', {})\
82
+            [int(description.output_file.wrapper.default_http_code)] = {
83
+            'description': str(description.output_file.wrapper.default_http_code),  # nopep8
84
+        }
85
+
77 86
     if description.errors:
78 87
         for error in description.errors:
79 88
             schema_class = type(error.wrapper.schema)
@@ -109,6 +118,16 @@ def bottle_generate_operations(
109 118
                 'type': schema['type']
110 119
             })
111 120
 
121
+    if description.input_files:
122
+        method_operations.setdefault('consume', []).append('multipart/form-data')
123
+        for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
124
+            method_operations.setdefault('parameters', []).append({
125
+                'in': 'formData',
126
+                'name': field_name,
127
+                'required': field.required,
128
+                'type': 'file',
129
+            })
130
+
112 131
     operations = {
113 132
         bottle_route.method.lower(): method_operations,
114 133
     }

+ 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
 

+ 45 - 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
 
@@ -148,6 +153,22 @@ class Hapic(object):
148 153
             return decoration.get_wrapper(func)
149 154
         return decorator
150 155
 
156
+    # TODO BS 20171102: Think about possibilities to validate output ? (with mime type, or validator)
157
+    def output_file(
158
+        self,
159
+        output_type: str,
160
+        default_http_code: HTTPStatus = HTTPStatus.OK,
161
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
162
+        decoration = OutputFileControllerWrapper(
163
+            output_type=output_type,
164
+            default_http_code=default_http_code,
165
+        )
166
+
167
+        def decorator(func):
168
+            self._buffer.output_file = OutputFileDescription(decoration)
169
+            return decoration.get_wrapper(func)
170
+        return decorator
171
+
151 172
     def input_headers(
152 173
         self,
153 174
         schema: typing.Any,
@@ -268,6 +289,30 @@ class Hapic(object):
268 289
             return decoration.get_wrapper(func)
269 290
         return decorator
270 291
 
292
+    def input_files(
293
+        self,
294
+        schema: typing.Any,
295
+        processor: ProcessorInterface=None,
296
+        context: ContextInterface=None,
297
+        error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
298
+        default_http_code: HTTPStatus = HTTPStatus.OK,
299
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
300
+        processor = processor or MarshmallowInputFilesProcessor()
301
+        processor.schema = schema
302
+        context = context or self._context_getter
303
+
304
+        decoration = InputFilesControllerWrapper(
305
+            context=context,
306
+            processor=processor,
307
+            error_http_code=error_http_code,
308
+            default_http_code=default_http_code,
309
+        )
310
+
311
+        def decorator(func):
312
+            self._buffer.input_files = InputFilesDescription(decoration)
313
+            return decoration.get_wrapper(func)
314
+        return decorator
315
+
271 316
     def handle_exception(
272 317
         self,
273 318
         handled_exception_class: typing.Type[Exception],

+ 72 - 2
hapic/processor.py View File

@@ -1,7 +1,8 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import typing
3 3
 
4
-from hapic.exception import InputValidationException, OutputValidationException
4
+from hapic.exception import OutputValidationException
5
+from hapic.exception import ConfigurationException
5 6
 
6 7
 
7 8
 class RequestParameters(object):
@@ -12,12 +13,14 @@ class RequestParameters(object):
12 13
         body_parameters,
13 14
         form_parameters,
14 15
         header_parameters,
16
+        files_parameters,
15 17
     ):
16 18
         self.path_parameters = path_parameters
17 19
         self.query_parameters = query_parameters
18 20
         self.body_parameters = body_parameters
19 21
         self.form_parameters = form_parameters
20 22
         self.header_parameters = header_parameters
23
+        self.files_parameters = files_parameters
21 24
 
22 25
 
23 26
 class ProcessValidationError(object):
@@ -32,7 +35,17 @@ class ProcessValidationError(object):
32 35
 
33 36
 class ProcessorInterface(object):
34 37
     def __init__(self):
35
-        self.schema = None
38
+        self._schema = None
39
+
40
+    @property
41
+    def schema(self):
42
+        if not self._schema:
43
+            raise ConfigurationException('Schema not set for processor {}'.format(str(self)))
44
+        return self._schema
45
+
46
+    @schema.setter
47
+    def schema(self, schema):
48
+        self._schema = schema
36 49
 
37 50
     def process(self, value):
38 51
         raise NotImplementedError
@@ -108,3 +121,60 @@ class MarshmallowInputProcessor(InputProcessor):
108 121
             message='Validation error of input data',
109 122
             details=marshmallow_errors,
110 123
         )
124
+
125
+
126
+class MarshmallowInputFilesProcessor(MarshmallowInputProcessor):
127
+    def process(self, data: dict):
128
+        clean_data = self.clean_data(data)
129
+        unmarshall = self.schema.load(clean_data)
130
+        additional_errors = self._get_files_errors(unmarshall.data)
131
+
132
+        if unmarshall.errors:
133
+            raise OutputValidationException(
134
+                'Error when validate ouput: {}'.format(
135
+                    str(unmarshall.errors),
136
+                )
137
+            )
138
+
139
+        if additional_errors:
140
+            raise OutputValidationException(
141
+                'Error when validate ouput: {}'.format(
142
+                    str(additional_errors),
143
+                )
144
+            )
145
+
146
+        return unmarshall.data
147
+
148
+    def get_validation_error(self, data: dict) -> ProcessValidationError:
149
+        clean_data = self.clean_data(data)
150
+        unmarshall = self.schema.load(clean_data)
151
+        marshmallow_errors = unmarshall.errors
152
+        additional_errors = self._get_files_errors(unmarshall.data)
153
+
154
+        if marshmallow_errors:
155
+            return ProcessValidationError(
156
+                message='Validation error of input data',
157
+                details=marshmallow_errors,
158
+            )
159
+
160
+        if additional_errors:
161
+            return ProcessValidationError(
162
+                message='Validation error of input data',
163
+                details=additional_errors,
164
+            )
165
+
166
+    def _get_files_errors(self, validated_data: dict) -> typing.Dict[str, str]:
167
+        """
168
+        Additional check of data
169
+        :param validated_data: previously validated data by marshmallow schema
170
+        :return: list of error if any
171
+        """
172
+        errors = {}
173
+
174
+        for field_name, field in self.schema.fields.items():
175
+            # Actually just check if value not empty
176
+            # TODO BS 20171102: Think about case where test file content is more complicated
177
+            if field.required and (field_name not in validated_data or not validated_data[field_name]):
178
+                errors.setdefault(field_name, []).append('Missing data for required field')
179
+
180
+        return errors

+ 55 - 0
tests/base.py View File

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

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

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

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

@@ -0,0 +1,93 @@
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 'consume' in doc['paths']['/upload']['post']
31
+        assert 'multipart/form-data' in doc['paths']['/upload']['post']['consume']
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 'consume' in doc['paths']['/upload']['post']
61
+        assert 'multipart/form-data' in doc['paths']['/upload']['post']['consume']
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 'produce' in doc['paths']['/avatar']['get']
92
+        assert 'image/jpeg' in doc['paths']['/avatar']['get']['produce']
93
+        assert 200 in doc['paths']['/avatar']['get']['responses']

+ 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

+ 10 - 39
tests/unit/test_decorator.py View File

@@ -4,49 +4,18 @@ from http import HTTPStatus
4 4
 
5 5
 import marshmallow
6 6
 
7
-from hapic.context import ContextInterface
8 7
 from hapic.data import HapicData
9
-from hapic.decorator import InputOutputControllerWrapper
10 8
 from hapic.decorator import ExceptionHandlerControllerWrapper
11 9
 from hapic.decorator import InputControllerWrapper
10
+from hapic.decorator import InputOutputControllerWrapper
12 11
 from hapic.decorator import OutputControllerWrapper
13 12
 from hapic.hapic import ErrorResponseSchema
14
-from hapic.processor import RequestParameters
15 13
 from hapic.processor import MarshmallowOutputProcessor
16 14
 from hapic.processor import ProcessValidationError
17 15
 from hapic.processor import ProcessorInterface
16
+from hapic.processor import RequestParameters
18 17
 from tests.base import Base
19
-
20
-
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
-        }
40
-
41
-    def get_validation_error_response(
42
-        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
-        }
18
+from tests.base import MyContext
50 19
 
51 20
 
52 21
 class MyProcessor(ProcessorInterface):
@@ -82,12 +51,12 @@ class MyControllerWrapper(InputOutputControllerWrapper):
82 51
         return response * 2
83 52
 
84 53
 
85
-class MyInputControllerWrapper(InputControllerWrapper):
54
+class MyInputQueryControllerWrapper(InputControllerWrapper):
86 55
     def get_processed_data(
87 56
         self,
88 57
         request_parameters: RequestParameters,
89 58
     ) -> typing.Any:
90
-        return {'we_are_testing': request_parameters.path_parameters}
59
+        return request_parameters.query_parameters
91 60
 
92 61
     def update_hapic_data(
93 62
         self,
@@ -146,16 +115,18 @@ class TestControllerWrapper(Base):
146 115
 
147 116
 class TestInputControllerWrapper(Base):
148 117
     def test_unit__input_data_wrapping__ok__nominal_case(self):
149
-        context = MyContext()
118
+        context = MyContext(fake_query_parameters={
119
+            'foo': 'bar',
120
+        })
150 121
         processor = MyProcessor()
151
-        wrapper = MyInputControllerWrapper(context, processor)
122
+        wrapper = MyInputQueryControllerWrapper(context, processor)
152 123
 
153 124
         @wrapper.get_wrapper
154 125
         def func(foo, hapic_data=None):
155 126
             assert hapic_data
156 127
             assert isinstance(hapic_data, HapicData)
157 128
             # see MyControllerWrapper#before_wrapped_func
158
-            assert hapic_data.query == {'we_are_testing': {'fake': (42,)}}
129
+            assert hapic_data.query == {'foo': 'bar'}
159 130
             return foo
160 131
 
161 132
         result = func(42)