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,6 +20,12 @@ class HelloPathSchema(marshmallow.Schema):
20 20
     )
21 21
 
22 22
 
23
+class HelloQuerySchema(marshmallow.Schema):
24
+    alive = marshmallow.fields.Boolean(
25
+        required=False,
26
+    )
27
+
28
+
23 29
 class HelloJsonSchema(marshmallow.Schema):
24 30
     color =marshmallow.fields.String(
25 31
         required=True,

+ 36 - 3
example_a.py View File

@@ -1,10 +1,14 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
2 3
 from http import HTTPStatus
3 4
 
4 5
 import bottle
6
+import time
7
+import yaml
8
+
5 9
 import hapic
6 10
 from example import HelloResponseSchema, HelloPathSchema, HelloJsonSchema, \
7
-    ErrorResponseSchema
11
+    ErrorResponseSchema, HelloQuerySchema
8 12
 from hapic.data import HapicData
9 13
 
10 14
 app = bottle.Bottle()
@@ -20,8 +24,24 @@ def bob(f):
20 24
 # @hapic.ext.bottle.bottle_context()
21 25
 @hapic.handle_exception(ZeroDivisionError, http_code=HTTPStatus.BAD_REQUEST)
22 26
 @hapic.input_path(HelloPathSchema())
27
+@hapic.input_query(HelloQuerySchema())
23 28
 @hapic.output_body(HelloResponseSchema())
24 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 45
     if name == 'zero':
26 46
         raise ZeroDivisionError('Don\'t call him zero !')
27 47
 
@@ -51,6 +71,7 @@ kwargs = {'validated_data': {'name': 'bob'}, 'name': 'bob'}
51 71
 @hapic.with_api_doc()
52 72
 # @hapic.ext.bottle.bottle_context()
53 73
 # @hapic.error_schema(ErrorResponseSchema())
74
+@hapic.input_path(HelloPathSchema())
54 75
 @hapic.output_body(HelloResponseSchema())
55 76
 def hello3(name: str):
56 77
     return {
@@ -60,8 +81,20 @@ def hello3(name: str):
60 81
 
61 82
 
62 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 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 100
 app.run(host='localhost', port=8080, debug=True)

+ 1 - 0
hapic/context.py View File

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

+ 2 - 1
hapic/decorator.py View File

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

+ 2 - 1
hapic/description.py View File

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

+ 171 - 0
hapic/doc.py View File

@@ -0,0 +1,171 @@
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,11 +1,8 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 import typing
3 3
 from http import HTTPStatus
4
-
5
-import bottle
6 4
 import functools
7 5
 
8
-# CHANGE
9 6
 import marshmallow
10 7
 
11 8
 from hapic.buffer import DecorationBuffer
@@ -18,16 +15,16 @@ from hapic.decorator import InputPathControllerWrapper
18 15
 from hapic.decorator import InputQueryControllerWrapper
19 16
 from hapic.decorator import OutputBodyControllerWrapper
20 17
 from hapic.decorator import OutputHeadersControllerWrapper
21
-from hapic.description import InputBodyDescription
18
+from hapic.description import InputBodyDescription, ErrorDescription
22 19
 from hapic.description import InputFormsDescription
23 20
 from hapic.description import InputHeadersDescription
24 21
 from hapic.description import InputPathDescription
25 22
 from hapic.description import InputQueryDescription
26 23
 from hapic.description import OutputBodyDescription
27 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 29
 # TODO: Gérer les erreurs de schema
33 30
 # TODO: Gérer les cas ou c'est une liste la réponse (items, item_nb)
@@ -47,7 +44,8 @@ _default_global_error_schema = ErrorResponseSchema()
47 44
 class Hapic(object):
48 45
     def __init__(self):
49 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 50
     def with_api_doc(self):
53 51
         def decorator(func):
@@ -250,22 +248,11 @@ class Hapic(object):
250 248
         )
251 249
 
252 250
         def decorator(func):
253
-            self._buffer.input_forms = InputFormsDescription(decoration)
251
+            self._buffer.errors.append(ErrorDescription(decoration))
254 252
             return decoration.get_wrapper(func)
255 253
         return decorator
256 254
 
257 255
     def generate_doc(self, app=None):
258 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,6 +11,7 @@ install_requires = [
11 11
     # TODO: marshmallow an extension too ?
12 12
     'bottle',
13 13
     'marshmallow',
14
+    'apispec',
14 15
 ]
15 16
 tests_require = [
16 17
     'pytest',