Browse Source

generation swagger doc: minimal working version

Bastien Sevajol 6 years ago
parent
commit
d848309253
8 changed files with 228 additions and 27 deletions
  1. 6 0
      example.py
  2. 36 3
      example_a.py
  3. 1 0
      hapic/context.py
  4. 2 1
      hapic/decorator.py
  5. 2 1
      hapic/description.py
  6. 171 0
      hapic/doc.py
  7. 9 22
      hapic/hapic.py
  8. 1 0
      setup.py

+ 6 - 0
example.py View File

20
     )
20
     )
21
 
21
 
22
 
22
 
23
+class HelloQuerySchema(marshmallow.Schema):
24
+    alive = marshmallow.fields.Boolean(
25
+        required=False,
26
+    )
27
+
28
+
23
 class HelloJsonSchema(marshmallow.Schema):
29
 class HelloJsonSchema(marshmallow.Schema):
24
     color =marshmallow.fields.String(
30
     color =marshmallow.fields.String(
25
         required=True,
31
         required=True,

+ 36 - 3
example_a.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import json
2
 from http import HTTPStatus
3
 from http import HTTPStatus
3
 
4
 
4
 import bottle
5
 import bottle
6
+import time
7
+import yaml
8
+
5
 import hapic
9
 import hapic
6
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
10
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
7
-    ErrorResponseSchema
11
+    ErrorResponseSchema, HelloQuerySchema
8
 from hapic.data import HapicData
12
 from hapic.data import HapicData
9
 
13
 
10
 app = bottle.Bottle()
14
 app = bottle.Bottle()
20
 # @hapic.ext.bottle.bottle_context()
24
 # @hapic.ext.bottle.bottle_context()
21
 @hapic.handle_exception(ZeroDivisionError, http_code=HTTPStatus.BAD_REQUEST)
25
 @hapic.handle_exception(ZeroDivisionError, http_code=HTTPStatus.BAD_REQUEST)
22
 @hapic.input_path(HelloPathSchema())
26
 @hapic.input_path(HelloPathSchema())
27
+@hapic.input_query(HelloQuerySchema())
23
 @hapic.output_body(HelloResponseSchema())
28
 @hapic.output_body(HelloResponseSchema())
24
 def hello(name: str, hapic_data: HapicData):
29
 def hello(name: str, hapic_data: HapicData):
30
+    """
31
+    my endpoint hello
32
+    ---
33
+    get:
34
+        description: my description
35
+        parameters:
36
+            - in: "path"
37
+              description: "hello"
38
+              name: "name"
39
+              type: "string"
40
+        responses:
41
+            200:
42
+                description: A pet to be returned
43
+                schema: HelloResponseSchema
44
+    """
25
     if name == 'zero':
45
     if name == 'zero':
26
         raise ZeroDivisionError('Don\'t call him zero !')
46
         raise ZeroDivisionError('Don\'t call him zero !')
27
 
47
 
51
 @hapic.with_api_doc()
71
 @hapic.with_api_doc()
52
 # @hapic.ext.bottle.bottle_context()
72
 # @hapic.ext.bottle.bottle_context()
53
 # @hapic.error_schema(ErrorResponseSchema())
73
 # @hapic.error_schema(ErrorResponseSchema())
74
+@hapic.input_path(HelloPathSchema())
54
 @hapic.output_body(HelloResponseSchema())
75
 @hapic.output_body(HelloResponseSchema())
55
 def hello3(name: str):
76
 def hello3(name: str):
56
     return {
77
     return {
60
 
81
 
61
 
82
 
62
 app.route('/hello/<name>', callback=hello)
83
 app.route('/hello/<name>', callback=hello)
63
-app.route('/hello2/<name>', callback=hello2, method='POST')
84
+app.route('/hello/<name>', callback=hello2, method='POST')
64
 app.route('/hello3/<name>', callback=hello3)
85
 app.route('/hello3/<name>', callback=hello3)
65
 
86
 
66
-hapic.generate_doc(app)
87
+# time.sleep(1)
88
+# s = hapic.generate_doc(app)
89
+# ss = json.loads(json.dumps(s))
90
+# for path in ss['paths']:
91
+#     for method in ss['paths'][path]:
92
+#         for response_code in ss['paths'][path][method]['responses']:
93
+#             ss['paths'][path][method]['responses'][int(response_code)] = ss['paths'][path][method]['responses'][response_code]
94
+#             del ss['paths'][path][method]['responses'][int(response_code)]
95
+# print(yaml.dump(ss, default_flow_style=False))
96
+# time.sleep(1)
97
+
98
+print(json.dumps(hapic.generate_doc(app)))
99
+
67
 app.run(host='localhost', port=8080, debug=True)
100
 app.run(host='localhost', port=8080, debug=True)

+ 1 - 0
hapic/context.py View File

29
         raise NotImplementedError()
29
         raise NotImplementedError()
30
 
30
 
31
 
31
 
32
+# TODO: In extension
32
 class BottleContext(ContextInterface):
33
 class BottleContext(ContextInterface):
33
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
34
     def get_request_parameters(self, *args, **kwargs) -> RequestParameters:
34
         return RequestParameters(
35
         return RequestParameters(

+ 2 - 1
hapic/decorator.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
+import functools
2
 import typing
3
 import typing
3
 from http import HTTPStatus
4
 from http import HTTPStatus
4
 
5
 
35
             response = self._execute_wrapped_function(func, args, kwargs)
36
             response = self._execute_wrapped_function(func, args, kwargs)
36
             new_response = self.after_wrapped_function(response)
37
             new_response = self.after_wrapped_function(response)
37
             return new_response
38
             return new_response
38
-        return wrapper
39
+        return functools.update_wrapper(wrapper, func)
39
 
40
 
40
     def _execute_wrapped_function(
41
     def _execute_wrapped_function(
41
         self,
42
         self,

+ 2 - 1
hapic/description.py View File

27
 
27
 
28
 
28
 
29
 class InputFormsDescription(Description):
29
 class InputFormsDescription(Description):
30
-    pass
30
+    def __init__(self, wrapper: 'ControllerWrapper') -> None:
31
+        self.wrapper = wrapper
31
 
32
 
32
 
33
 
33
 class OutputBodyDescription(Description):
34
 class OutputBodyDescription(Description):

+ 171 - 0
hapic/doc.py View File

1
+# -*- coding: utf-8 -*-
2
+import re
3
+import typing
4
+
5
+import bottle
6
+from apispec import APISpec, Path
7
+from apispec.ext.marshmallow.swagger import schema2jsonschema
8
+
9
+from hapic.decorator import DecoratedController
10
+
11
+
12
+# Bottle regular expression to locate url parameters
13
+from hapic.description import ControllerDescription
14
+
15
+BOTTLE_RE_PATH_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>')
16
+
17
+
18
+def bottle_route_for_view(reference, app):
19
+    for route in app.routes:
20
+        if route.callback == reference:
21
+            return route
22
+    # TODO: specialize exception
23
+    raise Exception('Not found')
24
+
25
+
26
+def bottle_generate_operations(
27
+    spec,
28
+    bottle_route: bottle.Route,
29
+    description: ControllerDescription,
30
+):
31
+    method_operations = dict()
32
+
33
+    # schema based
34
+    if description.input_body:
35
+        schema_class = type(description.input_body.wrapper.processor.schema)
36
+        method_operations.setdefault('parameters', []).append({
37
+            'in': 'body',
38
+            'name': 'body',
39
+            'schema': {
40
+                '$ref': '#/definitions/{}'.format(schema_class.__name__)
41
+            }
42
+        })
43
+
44
+    if description.output_body:
45
+        schema_class = type(description.output_body.wrapper.processor.schema)
46
+        method_operations.setdefault('responses', {})\
47
+            [int(description.output_body.wrapper.default_http_code)] = {
48
+                'description': str(description.output_body.wrapper.default_http_code),  # nopep8
49
+                'schema': {
50
+                    '$ref': '#/definitions/{}'.format(schema_class.__name__)
51
+                }
52
+            }
53
+
54
+    if description.errors:
55
+        for error in description.errors:
56
+            method_operations.setdefault('responses', {})\
57
+                [int(error.wrapper.http_code)] = {
58
+                    'description': str(error.wrapper.http_code),
59
+                }
60
+
61
+    # jsonschema based
62
+    if description.input_path:
63
+        schema_class = type(description.input_path.wrapper.processor.schema)
64
+        jsonschema = schema2jsonschema(schema_class, spec=spec)
65
+        for name, schema in jsonschema.get('properties', {}).items():
66
+            method_operations.setdefault('parameters', []).append({
67
+                'in': 'path',
68
+                'name': name,
69
+                'required': name in jsonschema.get('required', []),
70
+                'type': schema['type']
71
+            })
72
+
73
+    if description.input_query:
74
+        schema_class = type(description.input_query.wrapper.processor.schema)
75
+        jsonschema = schema2jsonschema(schema_class, spec=spec)
76
+        for name, schema in jsonschema.get('properties', {}).items():
77
+            method_operations.setdefault('parameters', []).append({
78
+                'in': 'query',
79
+                'name': name,
80
+                'required': name in jsonschema.get('required', []),
81
+                'type': schema['type']
82
+            })
83
+
84
+    operations = {
85
+        bottle_route.method.lower(): method_operations,
86
+    }
87
+
88
+    return operations
89
+
90
+
91
+class DocGenerator(object):
92
+    def get_doc(
93
+        self,
94
+        controllers: typing.List[DecoratedController],
95
+        app,
96
+    ) -> dict:
97
+        # TODO: Découper
98
+        # TODO: bottle specific code !
99
+        if not app:
100
+            app = bottle.default_app()
101
+        else:
102
+            bottle.default_app.push(app)
103
+        flatten = lambda l: [item for sublist in l for item in sublist]
104
+
105
+        spec = APISpec(
106
+            title='Swagger Petstore',
107
+            version='1.0.0',
108
+            plugins=[
109
+                'apispec.ext.bottle',
110
+                'apispec.ext.marshmallow',
111
+            ],
112
+        )
113
+
114
+        schemas = []
115
+        # parse schemas
116
+        for controller in controllers:
117
+            description = controller.description
118
+
119
+            if description.input_body:
120
+                schemas.append(type(
121
+                    description.input_body.wrapper.processor.schema
122
+                ))
123
+
124
+            if description.input_forms:
125
+                schemas.append(type(
126
+                    description.input_forms.wrapper.processor.schema
127
+                ))
128
+
129
+            if description.output_body:
130
+                schemas.append(type(
131
+                    description.output_body.wrapper.processor.schema
132
+                ))
133
+
134
+        for schema in set(schemas):
135
+            spec.definition(schema.__name__, schema=schema)
136
+
137
+        # add views
138
+        # with app.test_request_context():
139
+        paths = {}
140
+        for controller in controllers:
141
+            bottle_route = bottle_route_for_view(controller.reference, app)
142
+            swagger_path = BOTTLE_RE_PATH_URL.sub(r'{\1}', bottle_route.rule)
143
+
144
+            operations = bottle_generate_operations(
145
+                spec,
146
+                bottle_route,
147
+                controller.description,
148
+            )
149
+
150
+            path = Path(path=swagger_path, operations=operations)
151
+
152
+            if swagger_path in paths:
153
+                paths[swagger_path].update(path)
154
+            else:
155
+                paths[swagger_path] = path
156
+
157
+            spec.add_path(path)
158
+
159
+        return spec.to_dict()
160
+
161
+        # route_by_callbacks = []
162
+        # routes = flatten(app.router.dyna_routes.values())
163
+        # for path, path_regex, route, func_ in routes:
164
+        #     route_by_callbacks.append(route.callback)
165
+        #
166
+        # for description in self._controllers:
167
+        #     for path, path_regex, route, func_ in routes:
168
+        #         if route.callback == description.reference:
169
+        #             # TODO: use description to feed apispec
170
+        #             print(route.method, path, description)
171
+        #             continue

+ 9 - 22
hapic/hapic.py View File

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 import typing
2
 import typing
3
 from http import HTTPStatus
3
 from http import HTTPStatus
4
-
5
-import bottle
6
 import functools
4
 import functools
7
 
5
 
8
-# CHANGE
9
 import marshmallow
6
 import marshmallow
10
 
7
 
11
 from hapic.buffer import DecorationBuffer
8
 from hapic.buffer import DecorationBuffer
18
 from hapic.decorator import InputQueryControllerWrapper
15
 from hapic.decorator import InputQueryControllerWrapper
19
 from hapic.decorator import OutputBodyControllerWrapper
16
 from hapic.decorator import OutputBodyControllerWrapper
20
 from hapic.decorator import OutputHeadersControllerWrapper
17
 from hapic.decorator import OutputHeadersControllerWrapper
21
-from hapic.description import InputBodyDescription
18
+from hapic.description import InputBodyDescription, ErrorDescription
22
 from hapic.description import InputFormsDescription
19
 from hapic.description import InputFormsDescription
23
 from hapic.description import InputHeadersDescription
20
 from hapic.description import InputHeadersDescription
24
 from hapic.description import InputPathDescription
21
 from hapic.description import InputPathDescription
25
 from hapic.description import InputQueryDescription
22
 from hapic.description import InputQueryDescription
26
 from hapic.description import OutputBodyDescription
23
 from hapic.description import OutputBodyDescription
27
 from hapic.description import OutputHeadersDescription
24
 from hapic.description import OutputHeadersDescription
28
-from hapic.processor import ProcessorInterface, MarshmallowInputProcessor
29
-
30
-flatten = lambda l: [item for sublist in l for item in sublist]
25
+from hapic.doc import DocGenerator
26
+from hapic.processor import ProcessorInterface
27
+from hapic.processor import MarshmallowInputProcessor
31
 
28
 
32
 # TODO: Gérer les erreurs de schema
29
 # TODO: Gérer les erreurs de schema
33
 # TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb)
30
 # TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb)
47
 class Hapic(object):
44
 class Hapic(object):
48
     def __init__(self):
45
     def __init__(self):
49
         self._buffer = DecorationBuffer()
46
         self._buffer = DecorationBuffer()
50
-        self._controllers = []
47
+        self._controllers = []  # type: typing.List[DecoratedController]
48
+        # TODO: Permettre la surcharge des classes utilisés ci-dessous
51
 
49
 
52
     def with_api_doc(self):
50
     def with_api_doc(self):
53
         def decorator(func):
51
         def decorator(func):
250
         )
248
         )
251
 
249
 
252
         def decorator(func):
250
         def decorator(func):
253
-            self._buffer.input_forms = InputFormsDescription(decoration)
251
+            self._buffer.errors.append(ErrorDescription(decoration))
254
             return decoration.get_wrapper(func)
252
             return decoration.get_wrapper(func)
255
         return decorator
253
         return decorator
256
 
254
 
257
     def generate_doc(self, app=None):
255
     def generate_doc(self, app=None):
258
         # TODO @Damien bottle specific code !
256
         # TODO @Damien bottle specific code !
259
-        app = app or bottle.default_app()
260
-
261
-        route_by_callbacks = []
262
-        routes = flatten(app.router.dyna_routes.values())
263
-        for path, path_regex, route, func_ in routes:
264
-            route_by_callbacks.append(route.callback)
265
-
266
-        for description in self._controllers:
267
-            for path, path_regex, route, func_ in routes:
268
-                if route.callback == description.reference:
269
-                    # TODO: use description to feed apispec
270
-                    print(route.method, path, description)
271
-                    continue
257
+        doc_generator = DocGenerator()
258
+        return doc_generator.get_doc(self._controllers, app)

+ 1 - 0
setup.py View File

11
     # TODO: marshmallow an extension too ?
11
     # TODO: marshmallow an extension too ?
12
     'bottle',
12
     'bottle',
13
     'marshmallow',
13
     'marshmallow',
14
+    'apispec',
14
 ]
15
 ]
15
 tests_require = [
16
 tests_require = [
16
     'pytest',
17
     'pytest',