Browse Source

Merge branch 'feature/pyramid_and_generic_doc_generation' into feature/flask

Bastien Sevajol 6 years ago
parent
commit
b71841142d

+ 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

+ 2 - 0
.gitignore View File

6
 .cache
6
 .cache
7
 *.egg-info
7
 *.egg-info
8
 .coverage
8
 .coverage
9
+/build
10
+/dist

+ 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
 

+ 139 - 0
example_a2.py View File

1
+# -*- coding: utf-8 -*-
2
+import json
3
+from http import HTTPStatus
4
+
5
+from pyramid.view import view_config
6
+from pyramid.config import Configurator
7
+from wsgiref.simple_server import make_server
8
+import time
9
+import yaml
10
+from beaker.middleware import SessionMiddleware
11
+
12
+import hapic
13
+from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
14
+    ErrorResponseSchema, HelloQuerySchema
15
+from hapic.data import HapicData
16
+
17
+# hapic.global_exception_handler(UnAuthExc, StandardErrorSchema)
18
+# hapic.global_exception_handler(UnAuthExc2, StandardErrorSchema)
19
+# hapic.global_exception_handler(UnAuthExc3, StandardErrorSchema)
20
+# bottle.default_app.push(app)
21
+
22
+# session_opts = {
23
+#     'session.type': 'file',
24
+#     'session.data_dir': '/tmp',
25
+#     'session.cookie_expires': 3600,
26
+#     'session.auto': True
27
+# }
28
+# session_middleware = SessionMiddleware(
29
+#     app,
30
+#     session_opts,
31
+#     environ_key='beaker.session',
32
+#     key='beaker.session.id',
33
+# )
34
+# app = session_middleware.wrap_app
35
+
36
+
37
+def bob(f):
38
+    def boby(*args, **kwargs):
39
+        return f(*args, **kwargs)
40
+    return boby
41
+
42
+class Controllers(object):
43
+    
44
+    @hapic.with_api_doc()
45
+    # @hapic.ext.bottle.bottle_context()
46
+    @hapic.handle_exception(ZeroDivisionError, http_code=HTTPStatus.BAD_REQUEST)
47
+    @hapic.input_path(HelloPathSchema())
48
+    @hapic.input_query(HelloQuerySchema())
49
+    @hapic.output_body(HelloResponseSchema())
50
+    def hello(self,context,request,hapic_data: HapicData):
51
+        """
52
+        my endpoint hello
53
+        ---
54
+        get:
55
+            description: my description
56
+            parameters:
57
+                - in: "path"
58
+                  description: "hello"
59
+                  name: "name"
60
+                  type: "string"
61
+            responses:
62
+                200:
63
+                    description: A pet to be returned
64
+                    schema: HelloResponseSchema
65
+        """
66
+        name = request.matchdict.get('name', None)
67
+        if name == 'zero':
68
+            raise ZeroDivisionError('Don\'t call him zero !')
69
+
70
+        return {
71
+            'sentence': 'Hello !',
72
+            'name': name,
73
+       }
74
+
75
+    
76
+    # @hapic.with_api_doc()
77
+    # # @hapic.ext.bottle.bottle_context()
78
+    # # @hapic.error_schema(ErrorResponseSchema())
79
+    # #@hapic.input_path(HelloPathSchema())
80
+    # #@hapic.input_body(HelloJsonSchema())
81
+    # #@hapic.output_body(HelloResponseSchema())
82
+    # @bob
83
+    # def hello2(self, name: str, hapic_data: HapicData):
84
+    #     return {
85
+    #         'sentence': 'Hello !',
86
+    #         'name': name,
87
+    #         'color': hapic_data.body.get('color'),
88
+    #     }
89
+
90
+    # kwargs = {'validated_data': {'name': 'bob'}, 'name': 'bob'}
91
+
92
+    
93
+    # @view_config(renderer='json')
94
+    # @hapic.with_api_doc()
95
+    # # @hapic.ext.bottle.bottle_context()
96
+    # # @hapic.error_schema(ErrorResponseSchema())
97
+    # @hapic.input_path(HelloPathSchema())
98
+    # @hapic.output_body(HelloResponseSchema())
99
+    # def hello3(self, name: str):
100
+    #     return {
101
+    #         'sentence': 'Hello !',
102
+    #         'name': name,
103
+    #     }
104
+
105
+    def bind(self, config):
106
+        config.add_route('hello', '/hello/{name}', request_method='GET')
107
+        #config.add_route('hello2', '/hello/{name}', request_method='POST')
108
+        #config.add_route('hello3', '/hello/{name}', request_method='GET')
109
+        config.add_view(self.hello, route_name='hello', renderer='json')
110
+        #config.add_view(self.hello2, route_name='hello2')
111
+        #config.add_view(self.hello3, route_name='hello3')
112
+
113
+
114
+with Configurator() as config:
115
+    controllers = Controllers()
116
+    controllers.bind(config)
117
+    config.include('pyramid_debugtoolbar')
118
+    app = config
119
+
120
+
121
+
122
+
123
+# time.sleep(1)
124
+# s = hapic.generate_doc(app)
125
+# ss = json.loads(json.dumps(s))
126
+# for path in ss['paths']:
127
+#     for method in ss['paths'][path]:
128
+#         for response_code in ss['paths'][path][method]['responses']:
129
+#             ss['paths'][path][method]['responses'][int(response_code)] = ss['paths'][path][method]['responses'][response_code]
130
+#             del ss['paths'][path][method]['responses'][int(response_code)]
131
+# print(yaml.dump(ss, default_flow_style=False))
132
+# time.sleep(1)
133
+
134
+hapic.set_context(hapic.ext.pyramid.PyramidContext())
135
+import pdb; pdb.set_trace()
136
+#print(json.dumps(hapic.generate_doc(app)))
137
+# app.run(host='localhost', port=8080, debug=True)
138
+server = make_server('0.0.0.0', 6543, app.make_wsgi_app())
139
+server.serve_forever()

+ 125 - 0
example_a_pyramid.py View File

1
+# -*- coding: utf-8 -*-
2
+import json
3
+from http import HTTPStatus
4
+
5
+#import bottle
6
+from pyramid.config import Configurator
7
+from wsgiref.simple_server import make_server
8
+import time
9
+import yaml
10
+import uuid
11
+from beaker.middleware import SessionMiddleware
12
+
13
+import hapic
14
+from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
15
+    ErrorResponseSchema, HelloQuerySchema
16
+from hapic.data import HapicData
17
+
18
+# hapic.global_exception_handler(UnAuthExc, StandardErrorSchema)
19
+# hapic.global_exception_handler(UnAuthExc2, StandardErrorSchema)
20
+# hapic.global_exception_handler(UnAuthExc3, StandardErrorSchema)
21
+# bottle.default_app.push(app)
22
+
23
+# session_opts = {
24
+#     'session.type': 'file',
25
+#     'session.data_dir': '/tmp',
26
+#     'session.cookie_expires': 3600,
27
+#     'session.auto': True
28
+# }
29
+# session_middleware = SessionMiddleware(
30
+#     app,
31
+#     session_opts,
32
+#     environ_key='beaker.session',
33
+#     key='beaker.session.id',
34
+# )
35
+# app = session_middleware.wrap_app
36
+
37
+
38
+def bob(f):
39
+    def boby(*args, **kwargs):
40
+        return f(*args, **kwargs)
41
+    return boby
42
+
43
+
44
+class Controllers(object):
45
+    @hapic.with_api_doc()
46
+    # @hapic.ext.bottle.bottle_context()
47
+    @hapic.handle_exception(ZeroDivisionError, http_code=HTTPStatus.BAD_REQUEST)
48
+    @hapic.input_path(HelloPathSchema())
49
+    @hapic.input_query(HelloQuerySchema())
50
+    @hapic.output_body(HelloResponseSchema())
51
+    def hello(self, context, request, hapic_data: HapicData):
52
+        """
53
+        my endpoint hello
54
+        ---
55
+        get:
56
+            description: my description
57
+            parameters:
58
+                - in: "path"
59
+                  description: "hello"
60
+                  name: "name"
61
+                  type: "string"
62
+            responses:
63
+                200:
64
+                    description: A pet to be returned
65
+                    schema: HelloResponseSchema
66
+        """
67
+        name = request.matchdict.get('name', None)
68
+        if name == 'zero':
69
+            raise ZeroDivisionError('Don\'t call him zero !')
70
+
71
+        return {
72
+            'sentence': 'Hello !',
73
+            'name': name,
74
+        }
75
+
76
+    @hapic.with_api_doc()
77
+    # @hapic.ext.bottle.bottle_context()
78
+    # @hapic.error_schema(ErrorResponseSchema())
79
+    @hapic.input_path(HelloPathSchema())
80
+    @hapic.input_body(HelloJsonSchema())
81
+    @hapic.output_body(HelloResponseSchema())
82
+    @bob
83
+    def hello2(self, context, request, hapic_data: HapicData):
84
+        name = request.matchdict.get('name', None)
85
+        return {
86
+            'sentence': 'Hello !',
87
+            'name': name,
88
+            'color': hapic_data.body.get('color'),
89
+        }
90
+
91
+    kwargs = {'validated_data': {'name': 'bob'}, 'name': 'bob'}
92
+
93
+    @hapic.with_api_doc()
94
+    # @hapic.ext.bottle.bottle_context()
95
+    # @hapic.error_schema(ErrorResponseSchema())
96
+    @hapic.input_path(HelloPathSchema())
97
+    @hapic.output_body(HelloResponseSchema())
98
+    def hello3(self, context, request, hapic_data: HapicData):
99
+        name = request.matchdict.get('name', None)
100
+        return {
101
+            'sentence': 'Hello !',
102
+            'name': name,
103
+        }
104
+
105
+    def bind(self, configurator: Configurator):
106
+        configurator.add_route('hello', '/hello/{name}', request_method='GET')
107
+        configurator.add_view(self.hello, route_name='hello', renderer='json')
108
+
109
+        configurator.add_route('hello2', '/hello/{name}', request_method='POST')  # nopep8
110
+        configurator.add_view(self.hello2, route_name='hello2', renderer='json')  # nopep8
111
+
112
+        configurator.add_route('hello3', '/hello3/{name}', request_method='GET')  # nopep8
113
+        configurator.add_view(self.hello3, route_name='hello3', renderer='json')  # nopep8
114
+
115
+
116
+configurator = Configurator(autocommit=True)
117
+controllers = Controllers()
118
+
119
+controllers.bind(configurator)
120
+
121
+hapic.set_context(hapic.ext.pyramid.PyramidContext(configurator))
122
+print(json.dumps(hapic.generate_doc()))
123
+
124
+server = make_server('0.0.0.0', 8080, configurator.make_wsgi_app())
125
+server.serve_forever()

+ 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
 

+ 30 - 5
hapic/context.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-import json
3
 import typing
2
 import typing
4
 from http import HTTPStatus
3
 from http import HTTPStatus
5
 
4
 
6
-import bottle
5
+from hapic.processor import RequestParameters
6
+from hapic.processor import ProcessValidationError
7
 
7
 
8
-from hapic.exception import OutputValidationException
9
-# from hapic.hapic import _default_global_error_schema
10
-from hapic.processor import RequestParameters, ProcessValidationError
8
+if typing.TYPE_CHECKING:
9
+    from hapic.decorator import DecoratedController
10
+
11
+
12
+class RouteRepresentation(object):
13
+    def __init__(
14
+        self,
15
+        rule: str,
16
+        method: str,
17
+        original_route_object: typing.Any=None,
18
+    ) -> None:
19
+        self.rule = rule
20
+        self.method = method
21
+        self.original_route_object = original_route_object
11
 
22
 
12
 
23
 
13
 class ContextInterface(object):
24
 class ContextInterface(object):
27
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
38
         http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
28
     ) -> typing.Any:
39
     ) -> typing.Any:
29
         raise NotImplementedError()
40
         raise NotImplementedError()
41
+
42
+    def find_route(
43
+        self,
44
+        decorated_controller: 'DecoratedController',
45
+    ) -> RouteRepresentation:
46
+        raise NotImplementedError()
47
+
48
+    def get_swagger_path(self, contextualised_rule: str) -> str:
49
+        """
50
+        Return OpenAPI path with context path
51
+        :param contextualised_rule: path of original context
52
+        :return: OpenAPI path
53
+        """
54
+        raise NotImplementedError()

+ 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 []

+ 39 - 63
hapic/doc.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-import re
3
 import typing
2
 import typing
4
 
3
 
5
-import bottle
6
 from apispec import APISpec
4
 from apispec import APISpec
7
 from apispec import Path
5
 from apispec import Path
8
 from apispec.ext.marshmallow.swagger import schema2jsonschema
6
 from apispec.ext.marshmallow.swagger import schema2jsonschema
9
 
7
 
8
+from hapic.context import ContextInterface
9
+from hapic.context import RouteRepresentation
10
 from hapic.decorator import DecoratedController
10
 from hapic.decorator import DecoratedController
11
-from hapic.decorator import DECORATION_ATTRIBUTE_NAME
12
 from hapic.description import ControllerDescription
11
 from hapic.description import ControllerDescription
13
-from hapic.exception import NoRoutesException
14
-from hapic.exception import RouteNotFound
15
-
16
-# Bottle regular expression to locate url parameters
17
-BOTTLE_RE_PATH_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
18
-
19
-
20
-def find_bottle_route(
21
-    decorated_controller: DecoratedController,
22
-    app: bottle.Bottle,
23
-):
24
-    if not app.routes:
25
-        raise NoRoutesException('There is no routes in yout bottle app')
26
-
27
-    reference = decorated_controller.reference
28
-    for route in app.routes:
29
-        route_token = getattr(
30
-            route.callback,
31
-            DECORATION_ATTRIBUTE_NAME,
32
-            None,
33
-        )
34
-
35
-        match_with_wrapper = route.callback == reference.wrapper
36
-        match_with_wrapped = route.callback == reference.wrapped
37
-        match_with_token = route_token == reference.token
38
-
39
-        if match_with_wrapper or match_with_wrapped or match_with_token:
40
-            return route
41
-    # TODO BS 20171010: Raise exception or print error ? see #10
42
-    raise RouteNotFound(
43
-        'Decorated route "{}" was not found in bottle routes'.format(
44
-            decorated_controller.name,
45
-        )
46
-    )
47
 
12
 
48
 
13
 
49
 def bottle_generate_operations(
14
 def bottle_generate_operations(
50
     spec,
15
     spec,
51
-    bottle_route: bottle.Route,
16
+    route: RouteRepresentation,
52
     description: ControllerDescription,
17
     description: ControllerDescription,
53
 ):
18
 ):
54
     method_operations = dict()
19
     method_operations = dict()
74
                 }
39
                 }
75
             }
40
             }
76
 
41
 
42
+    if description.output_file:
43
+        method_operations.setdefault('produces', []).extend(
44
+            description.output_file.wrapper.output_types
45
+        )
46
+        method_operations.setdefault('responses', {})\
47
+            [int(description.output_file.wrapper.default_http_code)] = {
48
+            'description': str(description.output_file.wrapper.default_http_code),  # nopep8
49
+        }
50
+
77
     if description.errors:
51
     if description.errors:
78
         for error in description.errors:
52
         for error in description.errors:
79
             schema_class = type(error.wrapper.schema)
53
             schema_class = type(error.wrapper.schema)
109
                 'type': schema['type']
83
                 'type': schema['type']
110
             })
84
             })
111
 
85
 
86
+    if description.input_files:
87
+        method_operations.setdefault('consumes', []).append('multipart/form-data')
88
+        for field_name, field in description.input_files.wrapper.processor.schema.fields.items():
89
+            method_operations.setdefault('parameters', []).append({
90
+                'in': 'formData',
91
+                'name': field_name,
92
+                'required': field.required,
93
+                'type': 'file',
94
+            })
95
+
112
     operations = {
96
     operations = {
113
-        bottle_route.method.lower(): method_operations,
97
+        route.method.lower(): method_operations,
114
     }
98
     }
115
 
99
 
116
     return operations
100
     return operations
120
     def get_doc(
104
     def get_doc(
121
         self,
105
         self,
122
         controllers: typing.List[DecoratedController],
106
         controllers: typing.List[DecoratedController],
123
-        app,
107
+        context: ContextInterface,
124
     ) -> dict:
108
     ) -> dict:
125
-        # TODO: Découper, see #11
126
-        # TODO: bottle specific code !, see #11
127
-        if not app:
128
-            app = bottle.default_app()
129
-        else:
130
-            bottle.default_app.push(app)
131
-        flatten = lambda l: [item for sublist in l for item in sublist]
132
-
133
         spec = APISpec(
109
         spec = APISpec(
134
             title='Swagger Petstore',
110
             title='Swagger Petstore',
135
             version='1.0.0',
111
             version='1.0.0',
136
             plugins=[
112
             plugins=[
137
-                'apispec.ext.bottle',
113
+                # 'apispec.ext.bottle',
138
                 'apispec.ext.marshmallow',
114
                 'apispec.ext.marshmallow',
139
             ],
115
             ],
116
+            schema_name_resolver_callable=generate_schema_name,
140
         )
117
         )
141
 
118
 
142
         schemas = []
119
         schemas = []
170
         # with app.test_request_context():
147
         # with app.test_request_context():
171
         paths = {}
148
         paths = {}
172
         for controller in controllers:
149
         for controller in controllers:
173
-            bottle_route = find_bottle_route(controller, app)
174
-            swagger_path = BOTTLE_RE_PATH_URL.sub(r'{\1}', bottle_route.rule)
150
+            route = context.find_route(controller)
151
+            swagger_path = context.get_swagger_path(route.rule)
175
 
152
 
176
             operations = bottle_generate_operations(
153
             operations = bottle_generate_operations(
177
                 spec,
154
                 spec,
178
-                bottle_route,
155
+                route,
179
                 controller.description,
156
                 controller.description,
180
             )
157
             )
181
 
158
 
159
+            # TODO BS 20171114: TMP code waiting refact of doc
160
+            doc_string = controller.reference.get_doc_string()
161
+            if doc_string:
162
+                for method in operations.keys():
163
+                    operations[method]['description'] = doc_string
164
+
182
             path = Path(path=swagger_path, operations=operations)
165
             path = Path(path=swagger_path, operations=operations)
183
 
166
 
184
             if swagger_path in paths:
167
             if swagger_path in paths:
190
 
173
 
191
         return spec.to_dict()
174
         return spec.to_dict()
192
 
175
 
193
-        # route_by_callbacks = []
194
-        # routes = flatten(app.router.dyna_routes.values())
195
-        # for path, path_regex, route, func_ in routes:
196
-        #     route_by_callbacks.append(route.callback)
197
-        #
198
-        # for description in self._controllers:
199
-        #     for path, path_regex, route, func_ in routes:
200
-        #         if route.callback == description.reference:
201
-        #             # TODO: use description to feed apispec
202
-        #             print(route.method, path, description)
203
-        #             continue
176
+
177
+# TODO BS 20171109: Must take care of already existing definition names
178
+def generate_schema_name(schema):
179
+    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
 

+ 3 - 1
hapic/ext/__init__.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-from hapic.ext import bottle,flask
2
+from hapic.ext import bottle
3
+from hapic.ext import pyramid
4
+from hapic.ext import flask

+ 63 - 6
hapic/ext/bottle/context.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import json
2
 import json
3
+import re
3
 import typing
4
 import typing
4
 from http import HTTPStatus
5
 from http import HTTPStatus
5
 
6
 
6
 import bottle
7
 import bottle
8
+from multidict import MultiDict
7
 
9
 
8
 from hapic.context import ContextInterface
10
 from hapic.context import ContextInterface
11
+from hapic.context import RouteRepresentation
12
+from hapic.decorator import DecoratedController
13
+from hapic.decorator import DECORATION_ATTRIBUTE_NAME
9
 from hapic.exception import OutputValidationException
14
 from hapic.exception import OutputValidationException
10
-from hapic.processor import RequestParameters, ProcessValidationError
15
+from hapic.exception import NoRoutesException
16
+from hapic.exception import RouteNotFound
17
+from hapic.processor import RequestParameters
18
+from hapic.processor import ProcessValidationError
19
+
20
+# Bottle regular expression to locate url parameters
21
+BOTTLE_RE_PATH_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
11
 
22
 
12
 
23
 
13
 class BottleContext(ContextInterface):
24
 class BottleContext(ContextInterface):
25
+    def __init__(self, app: bottle.Bottle):
26
+        self.app = app
27
+
14
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
28
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
29
+        path_parameters = dict(bottle.request.url_args)
30
+        query_parameters = MultiDict(bottle.request.query.allitems())
31
+        body_parameters = dict(bottle.request.json or {})
32
+        form_parameters = MultiDict(bottle.request.forms.allitems())
33
+        header_parameters = dict(bottle.request.headers)
34
+        files_parameters = dict(bottle.request.files)
35
+
15
         return RequestParameters(
36
         return RequestParameters(
16
-            path_parameters=bottle.request.url_args,
17
-            query_parameters=bottle.request.params,
18
-            body_parameters=bottle.request.json,
19
-            form_parameters=bottle.request.forms,
20
-            header_parameters=bottle.request.headers,
37
+            path_parameters=path_parameters,
38
+            query_parameters=query_parameters,
39
+            body_parameters=body_parameters,
40
+            form_parameters=form_parameters,
41
+            header_parameters=header_parameters,
42
+            files_parameters=files_parameters,
21
         )
43
         )
22
 
44
 
23
     def get_response(
45
     def get_response(
55
             ],
77
             ],
56
             status=int(http_code),
78
             status=int(http_code),
57
         )
79
         )
80
+
81
+    def find_route(
82
+        self,
83
+        decorated_controller: DecoratedController,
84
+    ) -> RouteRepresentation:
85
+        if not self.app.routes:
86
+            raise NoRoutesException('There is no routes in your bottle app')
87
+
88
+        reference = decorated_controller.reference
89
+        for route in self.app.routes:
90
+            route_token = getattr(
91
+                route.callback,
92
+                DECORATION_ATTRIBUTE_NAME,
93
+                None,
94
+            )
95
+
96
+            match_with_wrapper = route.callback == reference.wrapper
97
+            match_with_wrapped = route.callback == reference.wrapped
98
+            match_with_token = route_token == reference.token
99
+
100
+            if match_with_wrapper or match_with_wrapped or match_with_token:
101
+                return RouteRepresentation(
102
+                    rule=self.get_swagger_path(route.rule),
103
+                    method=route.method.lower(),
104
+                    original_route_object=route,
105
+                )
106
+        # TODO BS 20171010: Raise exception or print error ? see #10
107
+        raise RouteNotFound(
108
+            'Decorated route "{}" was not found in bottle routes'.format(
109
+                decorated_controller.name,
110
+            )
111
+        )
112
+
113
+    def get_swagger_path(self, contextualised_rule: str) -> str:
114
+        return BOTTLE_RE_PATH_URL.sub(r'{\1}', contextualised_rule)

+ 2 - 0
hapic/ext/pyramid/__init__.py View File

1
+# -*- coding: utf-8 -*-
2
+from hapic.ext.pyramid.context import PyramidContext

+ 121 - 0
hapic/ext/pyramid/context.py View File

1
+# -*- coding: utf-8 -*-
2
+import json
3
+import re
4
+import typing
5
+from http import HTTPStatus
6
+
7
+from hapic.context import ContextInterface
8
+from hapic.context import RouteRepresentation
9
+from hapic.decorator import DecoratedController
10
+from hapic.decorator import DECORATION_ATTRIBUTE_NAME
11
+from hapic.ext.bottle.context import BOTTLE_RE_PATH_URL
12
+from hapic.exception import OutputValidationException
13
+from hapic.processor import RequestParameters
14
+from hapic.processor import ProcessValidationError
15
+
16
+if typing.TYPE_CHECKING:
17
+    from pyramid.response import Response
18
+    from pyramid.config import Configurator
19
+
20
+# Bottle regular expression to locate url parameters
21
+PYRAMID_RE_PATH_URL = re.compile(r'')
22
+
23
+
24
+class PyramidContext(ContextInterface):
25
+    def __init__(self, configurator: 'Configurator'):
26
+        self.configurator = configurator
27
+
28
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
29
+        req = args[-1]  # TODO : Check
30
+        # TODO : move this code to check_json
31
+        # same idea as in : https://bottlepy.org/docs/dev/_modules/bottle.html#BaseRequest.json
32
+        if req.body and req.content_type in ('application/json', 'application/json-rpc'):
33
+            json_body = req.json_body
34
+            # TODO : raise exception if not correct , return 400 if uncorrect instead ?
35
+        else:
36
+            json_body = None
37
+
38
+        return RequestParameters(
39
+            path_parameters=req.matchdict,
40
+            query_parameters=req.GET,
41
+            body_parameters=json_body,
42
+            form_parameters=req.POST,
43
+            header_parameters=req.headers,
44
+        )
45
+
46
+    def get_response(
47
+        self,
48
+        response: dict,
49
+        http_code: int,
50
+    ) -> 'Response':
51
+        from pyramid.response import Response
52
+        return Response(
53
+            body=json.dumps(response),
54
+            headers=[
55
+                ('Content-Type', 'application/json'),
56
+            ],
57
+            status=http_code,
58
+        )
59
+
60
+    def get_validation_error_response(
61
+        self,
62
+        error: ProcessValidationError,
63
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
64
+    ) -> typing.Any:
65
+        # TODO BS 20171010: Manage error schemas, see #4
66
+        from pyramid.response import Response
67
+        from hapic.hapic import _default_global_error_schema
68
+        unmarshall = _default_global_error_schema.dump(error)
69
+        if unmarshall.errors:
70
+            raise OutputValidationException(
71
+                'Validation error during dump of error response: {}'.format(
72
+                    str(unmarshall.errors)
73
+                )
74
+            )
75
+
76
+        return Response(
77
+            body=json.dumps(unmarshall.data),
78
+            headers=[
79
+                ('Content-Type', 'application/json'),
80
+            ],
81
+            status=int(http_code),
82
+        )
83
+
84
+    def find_route(
85
+        self,
86
+        decorated_controller: DecoratedController,
87
+    ) -> RouteRepresentation:
88
+        for category in self.configurator.introspector.get_category('views'):
89
+            view_intr = category['introspectable']
90
+            route_intr = category['related']
91
+
92
+            reference = decorated_controller.reference
93
+            route_token = getattr(
94
+                view_intr.get('callable'),
95
+                DECORATION_ATTRIBUTE_NAME,
96
+                None,
97
+            )
98
+
99
+            match_with_wrapper = view_intr.get('callable') == reference.wrapper
100
+            match_with_wrapped = view_intr.get('callable') == reference.wrapped
101
+            match_with_token = route_token == reference.token
102
+
103
+            if match_with_wrapper or match_with_wrapped or match_with_token:
104
+                # TODO BS 20171107: C'est une liste de route sous pyramid !!!
105
+                # Mais de toute maniere les framework womme pyramid, flask
106
+                # peuvent avoir un controlleur pour plusieurs routes doc
107
+                # .find_route doit retourner une liste au lieu d'une seule
108
+                # route
109
+                route_pattern = route_intr[0].get('pattern')
110
+                route_method = route_intr[0].get('request_methods')[0]
111
+
112
+                return RouteRepresentation(
113
+                    rule=self.get_swagger_path(route_pattern),
114
+                    method=route_method,
115
+                    original_route_object=route_intr[0],
116
+                )
117
+
118
+    def get_swagger_path(self, contextualised_rule: str) -> str:
119
+        # TODO BS 20171110: Pyramid allow route like '/{foo:\d+}', so adapt
120
+        # and USE regular expression (see https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/urldispatch.html#custom-route-predicates)  # nopep8
121
+        return contextualised_rule

+ 51 - 7
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
 
50
         self._buffer = DecorationBuffer()
55
         self._buffer = DecorationBuffer()
51
         self._controllers = []  # type: typing.List[DecoratedController]
56
         self._controllers = []  # type: typing.List[DecoratedController]
52
         self._context = None  # type: ContextInterface
57
         self._context = None  # type: ContextInterface
58
+        self.doc_generator = DocGenerator()
53
 
59
 
54
         # This local function will be pass to different components
60
         # This local function will be pass to different components
55
         # who will need context but declared (like with decorator)
61
         # who will need context but declared (like with decorator)
148
             return decoration.get_wrapper(func)
154
             return decoration.get_wrapper(func)
149
         return decorator
155
         return decorator
150
 
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
+
151
     def input_headers(
174
     def input_headers(
152
         self,
175
         self,
153
         schema: typing.Any,
176
         schema: typing.Any,
203
         context: ContextInterface = None,
226
         context: ContextInterface = None,
204
         error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
227
         error_http_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
205
         default_http_code: HTTPStatus = HTTPStatus.OK,
228
         default_http_code: HTTPStatus = HTTPStatus.OK,
229
+        as_list: typing.List[str]=None,
206
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
230
     ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
207
         processor = processor or MarshmallowInputProcessor()
231
         processor = processor or MarshmallowInputProcessor()
208
         processor.schema = schema
232
         processor.schema = schema
213
             processor=processor,
237
             processor=processor,
214
             error_http_code=error_http_code,
238
             error_http_code=error_http_code,
215
             default_http_code=default_http_code,
239
             default_http_code=default_http_code,
240
+            as_list=as_list,
216
         )
241
         )
217
 
242
 
218
         def decorator(func):
243
         def decorator(func):
268
             return decoration.get_wrapper(func)
293
             return decoration.get_wrapper(func)
269
         return decorator
294
         return decorator
270
 
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
+
271
     def handle_exception(
320
     def handle_exception(
272
         self,
321
         self,
273
         handled_exception_class: typing.Type[Exception],
322
         handled_exception_class: typing.Type[Exception],
289
             return decoration.get_wrapper(func)
338
             return decoration.get_wrapper(func)
290
         return decorator
339
         return decorator
291
 
340
 
292
-    def generate_doc(self, app):
293
-        # FIXME: j'ai du tricher avec app, see #11
294
-        # FIXME @Damien bottle specific code ! see #11
295
-        # rendre ca generique
296
-        app = app or self._context.get_app()
297
-        doc_generator = DocGenerator()
298
-        return doc_generator.get_doc(self._controllers, app)
341
+    def generate_doc(self):
342
+        return self.doc_generator.get_doc(self._controllers, self.context)

+ 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

+ 60 - 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.ext.bottle import BottleContext
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
+# TODO BS 20171105: Make this bottle agnostic !
17
+class MyContext(BottleContext):
18
+    def __init__(
19
+        self,
20
+        app,
21
+        fake_path_parameters=None,
22
+        fake_query_parameters=None,
23
+        fake_body_parameters=None,
24
+        fake_form_parameters=None,
25
+        fake_header_parameters=None,
26
+        fake_files_parameters=None,
27
+    ) -> None:
28
+        super().__init__(app=app)
29
+        self.fake_path_parameters = fake_path_parameters or {}
30
+        self.fake_query_parameters = fake_query_parameters or MultiDict()
31
+        self.fake_body_parameters = fake_body_parameters or {}
32
+        self.fake_form_parameters = fake_form_parameters or MultiDict()
33
+        self.fake_header_parameters = fake_header_parameters or {}
34
+        self.fake_files_parameters = fake_files_parameters or {}
35
+
36
+    def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
37
+        return RequestParameters(
38
+            path_parameters=self.fake_path_parameters,
39
+            query_parameters=self.fake_query_parameters,
40
+            body_parameters=self.fake_body_parameters,
41
+            form_parameters=self.fake_form_parameters,
42
+            header_parameters=self.fake_header_parameters,
43
+            files_parameters=self.fake_files_parameters,
44
+        )
45
+
46
+    def get_response(
47
+        self,
48
+        response: dict,
49
+        http_code: int,
50
+    ) -> typing.Any:
51
+        return {
52
+            'original_response': response,
53
+            'http_code': http_code,
54
+        }
55
+
56
+    def get_validation_error_response(
57
+        self,
58
+        error: ProcessValidationError,
59
+        http_code: HTTPStatus=HTTPStatus.BAD_REQUEST,
60
+    ) -> typing.Any:
61
+        return {
62
+            'original_error': error,
63
+            'http_code': http_code,
64
+        }

+ 18 - 19
tests/ext/unit/test_bottle.py View File

2
 import bottle
2
 import bottle
3
 
3
 
4
 import hapic
4
 import hapic
5
-from hapic.doc import find_bottle_route
6
 from tests.base import Base
5
 from tests.base import Base
7
 
6
 
8
 
7
 
9
 class TestBottleExt(Base):
8
 class TestBottleExt(Base):
10
     def test_unit__map_binding__ok__decorated_function(self):
9
     def test_unit__map_binding__ok__decorated_function(self):
11
         hapic_ = hapic.Hapic()
10
         hapic_ = hapic.Hapic()
12
-        hapic_.set_context(hapic.ext.bottle.BottleContext())
13
-
14
         app = bottle.Bottle()
11
         app = bottle.Bottle()
12
+        context = hapic.ext.bottle.BottleContext(app=app)
13
+        hapic_.set_context(context)
15
 
14
 
16
         @hapic_.with_api_doc()
15
         @hapic_.with_api_doc()
17
         @app.route('/')
16
         @app.route('/')
20
 
19
 
21
         assert hapic_.controllers
20
         assert hapic_.controllers
22
         decoration = hapic_.controllers[0]
21
         decoration = hapic_.controllers[0]
23
-        route = find_bottle_route(decoration, app)
22
+        route = context.find_route(decoration)
24
 
23
 
25
         assert route
24
         assert route
26
-        assert route.callback != controller_a
27
-        assert route.callback == decoration.reference.wrapped
28
-        assert route.callback != decoration.reference.wrapper
25
+        assert route.original_route_object.callback != controller_a
26
+        assert route.original_route_object.callback == decoration.reference.wrapped  # nopep8
27
+        assert route.original_route_object.callback != decoration.reference.wrapper  # nopep8
29
 
28
 
30
     def test_unit__map_binding__ok__mapped_function(self):
29
     def test_unit__map_binding__ok__mapped_function(self):
31
         hapic_ = hapic.Hapic()
30
         hapic_ = hapic.Hapic()
32
-        hapic_.set_context(hapic.ext.bottle.BottleContext())
33
-
34
         app = bottle.Bottle()
31
         app = bottle.Bottle()
32
+        context = hapic.ext.bottle.BottleContext(app=app)
33
+        hapic_.set_context(context)
35
 
34
 
36
         @hapic_.with_api_doc()
35
         @hapic_.with_api_doc()
37
         def controller_a():
36
         def controller_a():
41
 
40
 
42
         assert hapic_.controllers
41
         assert hapic_.controllers
43
         decoration = hapic_.controllers[0]
42
         decoration = hapic_.controllers[0]
44
-        route = find_bottle_route(decoration, app)
43
+        route = context.find_route(decoration)
45
 
44
 
46
         assert route
45
         assert route
47
-        assert route.callback == controller_a
48
-        assert route.callback == decoration.reference.wrapper
49
-        assert route.callback != decoration.reference.wrapped
46
+        assert route.original_route_object.callback == controller_a
47
+        assert route.original_route_object.callback == decoration.reference.wrapper  # nopep8
48
+        assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
50
 
49
 
51
     def test_unit__map_binding__ok__mapped_method(self):
50
     def test_unit__map_binding__ok__mapped_method(self):
52
         hapic_ = hapic.Hapic()
51
         hapic_ = hapic.Hapic()
53
-        hapic_.set_context(hapic.ext.bottle.BottleContext())
54
-
55
         app = bottle.Bottle()
52
         app = bottle.Bottle()
53
+        context = hapic.ext.bottle.BottleContext(app=app)
54
+        hapic_.set_context(context)
56
 
55
 
57
         class MyControllers(object):
56
         class MyControllers(object):
58
             def bind(self, app):
57
             def bind(self, app):
67
 
66
 
68
         assert hapic_.controllers
67
         assert hapic_.controllers
69
         decoration = hapic_.controllers[0]
68
         decoration = hapic_.controllers[0]
70
-        route = find_bottle_route(decoration, app)
69
+        route = context.find_route(decoration)
71
 
70
 
72
         assert route
71
         assert route
73
         # Important note: instance controller_a method is
72
         # Important note: instance controller_a method is
74
         # not class controller_a, so no matches with callbacks
73
         # not class controller_a, so no matches with callbacks
75
-        assert route.callback != MyControllers.controller_a
76
-        assert route.callback != decoration.reference.wrapped
77
-        assert route.callback != decoration.reference.wrapper
74
+        assert route.original_route_object.callback != MyControllers.controller_a  # nopep8
75
+        assert route.original_route_object.callback != decoration.reference.wrapped  # nopep8
76
+        assert route.original_route_object.callback != decoration.reference.wrapper  # nopep8

+ 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
+        app = bottle.Bottle()
14
+        hapic.set_context(MyContext(app=app))
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()
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
+        app = bottle.Bottle()
43
+        hapic.set_context(MyContext(app=app))
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()
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
+        app = bottle.Bottle()
79
+        hapic.set_context(MyContext(app=app))
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()
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
+        app = bottle.Bottle()
98
+        hapic.set_context(MyContext(app=app))
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()
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
+        app = bottle.Bottle()
131
+        hapic.set_context(MyContext(app=app))
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()
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']

+ 82 - 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(
15
+            app=None,
16
+            fake_files_parameters={
17
+                'file_abc': '10101010101',
18
+            }
19
+        ))
20
+
21
+        class MySchema(marshmallow.Schema):
22
+            file_abc = marshmallow.fields.Raw(required=True)
23
+
24
+        @hapic.with_api_doc()
25
+        @hapic.input_files(MySchema())
26
+        def my_controller(hapic_data=None):
27
+            assert hapic_data
28
+            assert hapic_data.files
29
+            return 'OK'
30
+
31
+        result = my_controller()
32
+        assert 'OK' == result
33
+
34
+    def test_unit__input_files__ok__file_is_not_present(self):
35
+        hapic = Hapic()
36
+        hapic.set_context(MyContext(
37
+            app=None,
38
+            fake_files_parameters={
39
+                # No file here
40
+            }
41
+        ))
42
+
43
+        class MySchema(marshmallow.Schema):
44
+            file_abc = marshmallow.fields.Raw(required=True)
45
+
46
+        @hapic.with_api_doc()
47
+        @hapic.input_files(MySchema())
48
+        def my_controller(hapic_data=None):
49
+            assert hapic_data
50
+            assert hapic_data.files
51
+            return 'OK'
52
+
53
+        result = my_controller()
54
+        assert 'http_code' in result
55
+        assert HTTPStatus.BAD_REQUEST == result['http_code']
56
+        assert {
57
+                   'file_abc': ['Missing data for required field.']
58
+               } == result['original_error'].details
59
+
60
+    def test_unit__input_files__ok__file_is_empty_string(self):
61
+        hapic = Hapic()
62
+        hapic.set_context(MyContext(
63
+            app=None,
64
+            fake_files_parameters={
65
+                'file_abc': '',
66
+            }
67
+        ))
68
+
69
+        class MySchema(marshmallow.Schema):
70
+            file_abc = marshmallow.fields.Raw(required=True)
71
+
72
+        @hapic.with_api_doc()
73
+        @hapic.input_files(MySchema())
74
+        def my_controller(hapic_data=None):
75
+            assert hapic_data
76
+            assert hapic_data.files
77
+            return 'OK'
78
+
79
+        result = my_controller()
80
+        assert 'http_code' in result
81
+        assert HTTPStatus.BAD_REQUEST == result['http_code']
82
+        assert {'file_abc': ['Missing data for required field']} == result['original_error'].details

+ 93 - 44
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,
103
 
90
 
104
 class TestControllerWrapper(Base):
91
 class TestControllerWrapper(Base):
105
     def test_unit__base_controller_wrapper__ok__no_behaviour(self):
92
     def test_unit__base_controller_wrapper__ok__no_behaviour(self):
106
-        context = MyContext()
93
+        context = MyContext(app=None)
107
         processor = MyProcessor()
94
         processor = MyProcessor()
108
         wrapper = InputOutputControllerWrapper(context, processor)
95
         wrapper = InputOutputControllerWrapper(context, processor)
109
 
96
 
115
         assert result == 42
102
         assert result == 42
116
 
103
 
117
     def test_unit__base_controller__ok__replaced_response(self):
104
     def test_unit__base_controller__ok__replaced_response(self):
118
-        context = MyContext()
105
+        context = MyContext(app=None)
119
         processor = MyProcessor()
106
         processor = MyProcessor()
120
         wrapper = MyControllerWrapper(context, processor)
107
         wrapper = MyControllerWrapper(context, processor)
121
 
108
 
129
         assert {'error_response': 'we are testing'} == result
116
         assert {'error_response': 'we are testing'} == result
130
 
117
 
131
     def test_unit__controller_wrapper__ok__overload_input(self):
118
     def test_unit__controller_wrapper__ok__overload_input(self):
132
-        context = MyContext()
119
+        context = MyContext(app=None)
133
         processor = MyProcessor()
120
         processor = MyProcessor()
134
         wrapper = MyControllerWrapper(context, processor)
121
         wrapper = MyControllerWrapper(context, processor)
135
 
122
 
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(
137
+            app=None,
138
+            fake_query_parameters=MultiDict(
139
+                (
140
+                    ('foo', 'bar',),
141
+                )
142
+            )
143
+        )
150
         processor = MyProcessor()
144
         processor = MyProcessor()
151
-        wrapper = MyInputControllerWrapper(context, processor)
145
+        wrapper = MyInputQueryControllerWrapper(context, processor)
152
 
146
 
153
         @wrapper.get_wrapper
147
         @wrapper.get_wrapper
154
         def func(foo, hapic_data=None):
148
         def func(foo, hapic_data=None):
155
             assert hapic_data
149
             assert hapic_data
156
             assert isinstance(hapic_data, HapicData)
150
             assert isinstance(hapic_data, HapicData)
157
             # see MyControllerWrapper#before_wrapped_func
151
             # see MyControllerWrapper#before_wrapped_func
158
-            assert hapic_data.query == {'we_are_testing': {'fake': (42,)}}
152
+            assert hapic_data.query == {'foo': 'bar'}
159
             return foo
153
             return foo
160
 
154
 
161
         result = func(42)
155
         result = func(42)
162
         assert result == 42
156
         assert result == 42
163
 
157
 
158
+    def test_unit__multi_query_param_values__ok__use_as_list(self):
159
+        context = MyContext(
160
+            app=None,
161
+            fake_query_parameters=MultiDict(
162
+                (
163
+                    ('user_id', 'abc'),
164
+                    ('user_id', 'def'),
165
+                ),
166
+            )
167
+        )
168
+        processor = MySimpleProcessor()
169
+        wrapper = InputQueryControllerWrapper(
170
+            context,
171
+            processor,
172
+            as_list=['user_id'],
173
+        )
174
+
175
+        @wrapper.get_wrapper
176
+        def func(hapic_data=None):
177
+            assert hapic_data
178
+            assert isinstance(hapic_data, HapicData)
179
+            # see MyControllerWrapper#before_wrapped_func
180
+            assert ['abc', 'def'] == hapic_data.query.get('user_id')
181
+            return hapic_data.query.get('user_id')
182
+
183
+        result = func()
184
+        assert result == ['abc', 'def']
185
+
186
+    def test_unit__multi_query_param_values__ok__without_as_list(self):
187
+        context = MyContext(
188
+            app=None,
189
+            fake_query_parameters=MultiDict(
190
+                (
191
+                    ('user_id', 'abc'),
192
+                    ('user_id', 'def'),
193
+                ),
194
+            )
195
+        )
196
+        processor = MySimpleProcessor()
197
+        wrapper = InputQueryControllerWrapper(
198
+            context,
199
+            processor,
200
+        )
201
+
202
+        @wrapper.get_wrapper
203
+        def func(hapic_data=None):
204
+            assert hapic_data
205
+            assert isinstance(hapic_data, HapicData)
206
+            # see MyControllerWrapper#before_wrapped_func
207
+            assert 'abc' == hapic_data.query.get('user_id')
208
+            return hapic_data.query.get('user_id')
209
+
210
+        result = func()
211
+        assert result == 'abc'
212
+
164
 
213
 
165
 class TestOutputControllerWrapper(Base):
214
 class TestOutputControllerWrapper(Base):
166
     def test_unit__output_data_wrapping__ok__nominal_case(self):
215
     def test_unit__output_data_wrapping__ok__nominal_case(self):
167
-        context = MyContext()
216
+        context = MyContext(app=None)
168
         processor = MyProcessor()
217
         processor = MyProcessor()
169
         wrapper = OutputControllerWrapper(context, processor)
218
         wrapper = OutputControllerWrapper(context, processor)
170
 
219
 
182
                } == result
231
                } == result
183
 
232
 
184
     def test_unit__output_data_wrapping__fail__error_response(self):
233
     def test_unit__output_data_wrapping__fail__error_response(self):
185
-        context = MyContext()
234
+        context = MyContext(app=None)
186
         processor = MarshmallowOutputProcessor()
235
         processor = MarshmallowOutputProcessor()
187
         processor.schema = MySchema()
236
         processor.schema = MySchema()
188
         wrapper = OutputControllerWrapper(context, processor)
237
         wrapper = OutputControllerWrapper(context, processor)
204
 
253
 
205
 class TestExceptionHandlerControllerWrapper(Base):
254
 class TestExceptionHandlerControllerWrapper(Base):
206
     def test_unit__exception_handled__ok__nominal_case(self):
255
     def test_unit__exception_handled__ok__nominal_case(self):
207
-        context = MyContext()
256
+        context = MyContext(app=None)
208
         wrapper = ExceptionHandlerControllerWrapper(
257
         wrapper = ExceptionHandlerControllerWrapper(
209
             ZeroDivisionError,
258
             ZeroDivisionError,
210
             context,
259
             context,
232
                 super().__init__(*args, **kwargs)
281
                 super().__init__(*args, **kwargs)
233
                 self.error_dict = {}
282
                 self.error_dict = {}
234
 
283
 
235
-        context = MyContext()
284
+        context = MyContext(app=None)
236
         wrapper = ExceptionHandlerControllerWrapper(
285
         wrapper = ExceptionHandlerControllerWrapper(
237
             MyException,
286
             MyException,
238
             context,
287
             context,