Browse Source

Add swagger ui doc visualisation

Bastien Sevajol 3 years ago
parent
commit
64819802ee

+ 1 - 0
example/example_a_flask.py View File

@@ -90,5 +90,6 @@ controllers = Controllers()
90 90
 controllers.bind(app)
91 91
 
92 92
 hapic.set_context(FlaskContext(app))
93
+hapic.add_documentation_view('/api-doc', 'DOC', 'Generated doc')
93 94
 print(json.dumps(hapic.generate_doc()))
94 95
 app.run(host='localhost', port=8080, debug=True)

+ 1 - 0
hapic/__init__.py View File

@@ -16,4 +16,5 @@ output_body = _hapic_default.output_body
16 16
 output_file = _hapic_default.output_file
17 17
 generate_doc = _hapic_default.generate_doc
18 18
 set_context = _hapic_default.set_context
19
+add_documentation_view = _hapic_default.add_documentation_view
19 20
 handle_exception = _hapic_default.handle_exception

+ 28 - 1
hapic/context.py View File

@@ -34,8 +34,9 @@ class ContextInterface(object):
34 34
     def get_response(
35 35
         self,
36 36
         # TODO BS 20171228: rename into response_content
37
-        response: dict,
37
+        response: str,
38 38
         http_code: int,
39
+        mimetype: str='application/json',
39 40
     ) -> typing.Any:
40 41
         raise NotImplementedError()
41 42
 
@@ -79,6 +80,32 @@ class ContextInterface(object):
79 80
         """
80 81
         raise NotImplementedError()
81 82
 
83
+    def add_view(
84
+        self,
85
+        route: str,
86
+        http_method: str,
87
+        view_func: typing.Callable[..., typing.Any],
88
+    ) -> None:
89
+        """
90
+        This method must permit to add a view in current context
91
+        :param route: The route depending of framework format, ex "/foo"
92
+        :param http_method: HTTP method like GET, POST, etc ...
93
+        :param view_func: The view callable
94
+        """
95
+        raise NotImplementedError()
96
+
97
+    def serve_directory(
98
+        self,
99
+        route_prefix: str,
100
+        directory_path: str,
101
+    ) -> None:
102
+        """
103
+        Configure a path to serve a directory content
104
+        :param route_prefix: The base url for serve the directory, eg /static
105
+        :param directory_path: The file system path
106
+        """
107
+        raise NotImplementedError()
108
+
82 109
 
83 110
 class BaseContext(ContextInterface):
84 111
     def get_default_error_builder(self) -> ErrorBuilderInterface:

+ 4 - 2
hapic/decorator.py View File

@@ -1,4 +1,6 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
3
+
2 4
 import functools
3 5
 import typing
4 6
 try:  # Python 3.5+
@@ -221,7 +223,7 @@ class OutputControllerWrapper(InputOutputControllerWrapper):
221 223
 
222 224
             processed_response = self.processor.process(response)
223 225
             prepared_response = self.context.get_response(
224
-                processed_response,
226
+                json.dumps(processed_response),
225 227
                 self.default_http_code,
226 228
             )
227 229
             return prepared_response
@@ -432,7 +434,7 @@ class ExceptionHandlerControllerWrapper(ControllerWrapper):
432 434
                 )
433 435
 
434 436
             error_response = self.context.get_response(
435
-                response_content,
437
+                json.dumps(response_content),
436 438
                 self.http_code,
437 439
             )
438 440
             return error_response

+ 27 - 0
hapic/doc.py View File

@@ -1,5 +1,8 @@
1 1
 # -*- coding: utf-8 -*-
2
+import json
3
+
2 4
 import typing
5
+import yaml
3 6
 
4 7
 from apispec import APISpec
5 8
 from apispec import Path
@@ -188,6 +191,30 @@ class DocGenerator(object):
188 191
 
189 192
         return spec.to_dict()
190 193
 
194
+    def save_in_file(
195
+        self,
196
+        doc_file_path: str,
197
+        controllers: typing.List[DecoratedController],
198
+        context: ContextInterface,
199
+        title: str='',
200
+        description: str='',
201
+    ) -> None:
202
+        # generate this file
203
+        dict_doc = self.get_doc(
204
+            controllers=controllers,
205
+            context=context,
206
+            title=title,
207
+            description=description,
208
+        )
209
+        json_doc = json.dumps(dict_doc)
210
+
211
+        # We dump then load with json to use real scalar dict.
212
+        # If not, yaml dump dict-like objects
213
+        clean_dict_doc = json.loads(json_doc)
214
+        yaml_doc = yaml.dump(clean_dict_doc, default_flow_style=False)
215
+        with open(doc_file_path, 'w+') as doc_file:
216
+            doc_file.write(yaml_doc)
217
+
191 218
 
192 219
 # TODO BS 20171109: Must take care of already existing definition names
193 220
 def generate_schema_name(schema):

+ 4 - 3
hapic/ext/bottle/context.py View File

@@ -56,13 +56,14 @@ class BottleContext(BaseContext):
56 56
 
57 57
     def get_response(
58 58
         self,
59
-        response: dict,
59
+        response: str,
60 60
         http_code: int,
61
+        mimetype: str='application/json',
61 62
     ) -> bottle.HTTPResponse:
62 63
         return bottle.HTTPResponse(
63
-            body=json.dumps(response),
64
+            body=response,
64 65
             headers=[
65
-                ('Content-Type', 'application/json'),
66
+                ('Content-Type', mimetype),
66 67
             ],
67 68
             status=http_code,
68 69
         )

+ 36 - 3
hapic/ext/flask/context.py View File

@@ -18,6 +18,7 @@ from hapic.processor import ProcessValidationError
18 18
 from hapic.error import DefaultErrorBuilder
19 19
 from hapic.error import ErrorBuilderInterface
20 20
 from flask import Flask
21
+from flask import send_from_directory
21 22
 
22 23
 if typing.TYPE_CHECKING:
23 24
     from flask import Response
@@ -49,13 +50,14 @@ class FlaskContext(BaseContext):
49 50
 
50 51
     def get_response(
51 52
         self,
52
-        response: dict,
53
+        response: str,
53 54
         http_code: int,
55
+        mimetype: str='application/json',
54 56
     ) -> 'Response':
55 57
         from flask import Response
56 58
         return Response(
57
-            response=json.dumps(response),
58
-            mimetype='application/json',
59
+            response=response,
60
+            mimetype=mimetype,
59 61
             status=http_code,
60 62
         )
61 63
 
@@ -123,3 +125,34 @@ class FlaskContext(BaseContext):
123 125
     def by_pass_output_wrapping(self, response: typing.Any) -> bool:
124 126
         from flask import Response
125 127
         return isinstance(response, Response)
128
+
129
+    def add_view(
130
+        self,
131
+        route: str,
132
+        http_method: str,
133
+        view_func: typing.Callable[..., typing.Any],
134
+    ) -> None:
135
+        self.app.add_url_rule(
136
+            rule=route,
137
+            view_func=view_func,
138
+        )
139
+
140
+    def serve_directory(
141
+        self,
142
+        route_prefix: str,
143
+        directory_path: str,
144
+    ) -> None:
145
+        if not route_prefix.endswith('/'):
146
+            route_prefix = '{}/'.format(route_prefix)
147
+
148
+        @self.app.route(
149
+            route_prefix,
150
+            defaults={
151
+                'path': 'index.html',
152
+            }
153
+        )
154
+        @self.app.route(
155
+            '{}<path:path>'.format(route_prefix),
156
+        )
157
+        def api_doc(path):
158
+            return send_from_directory(directory_path, path)

+ 4 - 3
hapic/ext/pyramid/context.py View File

@@ -57,14 +57,15 @@ class PyramidContext(BaseContext):
57 57
 
58 58
     def get_response(
59 59
         self,
60
-        response: dict,
60
+        response: str,
61 61
         http_code: int,
62
+        mimetype: str='application/json',
62 63
     ) -> 'Response':
63 64
         from pyramid.response import Response
64 65
         return Response(
65
-            body=json.dumps(response),
66
+            body=response,
66 67
             headers=[
67
-                ('Content-Type', 'application/json'),
68
+                ('Content-Type', mimetype),
68 69
             ],
69 70
             status=http_code,
70 71
         )

+ 71 - 0
hapic/hapic.py View File

@@ -1,4 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
+import os
2 3
 import typing
3 4
 import uuid
4 5
 import functools
@@ -376,3 +377,73 @@ class Hapic(object):
376 377
             title=title,
377 378
             description=description,
378 379
         )
380
+
381
+    def save_doc_in_file(
382
+        self,
383
+        file_path: str,
384
+        title: str='',
385
+        description: str='',
386
+    ) -> None:
387
+        """
388
+        See hapic.doc.DocGenerator#get_doc docstring
389
+        :param file_path: The file path to write doc in YAML format
390
+        :param title: Title of generated doc
391
+        :param description: Description of generated doc
392
+        """
393
+        self.doc_generator.save_in_file(
394
+            file_path,
395
+            controllers=self._controllers,
396
+            context=self.context,
397
+            title=title,
398
+            description=description,
399
+        )
400
+
401
+    def add_documentation_view(
402
+        self,
403
+        route: str,
404
+        title: str='',
405
+        description: str='',
406
+    ) -> None:
407
+        # Ensure "/" at end of route, else web browser will not consider it as
408
+        # a path
409
+        if not route.endswith('/'):
410
+            route = '{}/'.format(route)
411
+
412
+        # Add swagger directory as served static dir
413
+        swaggerui_path = os.path.join(
414
+            os.path.dirname(os.path.abspath(__file__)),
415
+            'static',
416
+            'swaggerui',
417
+        )
418
+        self.context.serve_directory(
419
+            route,
420
+            swaggerui_path,
421
+        )
422
+
423
+        # Generate documentation file
424
+        doc_page_path = os.path.join(swaggerui_path, 'spec.yml')
425
+        self.save_doc_in_file(doc_page_path)
426
+
427
+        # Prepare views html content
428
+        doc_index_path = os.path.join(swaggerui_path, 'index.html')
429
+        with open(doc_index_path, 'r') as doc_page:
430
+            doc_page_content = doc_page.read()
431
+        doc_page_content = doc_page_content.replace(
432
+            '{{ spec_uri }}',
433
+            'spec.yml',
434
+        )
435
+
436
+        # Declare the swaggerui view
437
+        def api_doc_view():
438
+            return self.context.get_response(
439
+                doc_page_content,
440
+                http_code=HTTPStatus.OK,
441
+                mimetype='text/html',
442
+            )
443
+
444
+        # Add a view to generate the html index page of swaggerui
445
+        self.context.add_view(
446
+            route=route,
447
+            http_method='GET',
448
+            view_func=api_doc_view,
449
+        )

BIN
hapic/static/swaggerui/favicon-16x16.png View File


BIN
hapic/static/swaggerui/favicon-32x32.png View File


+ 95 - 0
hapic/static/swaggerui/index.html View File

@@ -0,0 +1,95 @@
1
+<!-- HTML for static distribution bundle build -->
2
+<!DOCTYPE html>
3
+<html lang="en">
4
+<head>
5
+  <meta charset="UTF-8">
6
+  <title>Swagger UI</title>
7
+  <link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700" rel="stylesheet">
8
+  <link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
9
+  <link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
10
+  <link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
11
+  <style>
12
+    html
13
+    {
14
+      box-sizing: border-box;
15
+      overflow: -moz-scrollbars-vertical;
16
+      overflow-y: scroll;
17
+    }
18
+    *,
19
+    *:before,
20
+    *:after
21
+    {
22
+      box-sizing: inherit;
23
+    }
24
+
25
+    body {
26
+      margin:0;
27
+      background: #fafafa;
28
+    }
29
+  </style>
30
+</head>
31
+
32
+<body>
33
+
34
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position:absolute;width:0;height:0">
35
+  <defs>
36
+    <symbol viewBox="0 0 20 20" id="unlocked">
37
+          <path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z"></path>
38
+    </symbol>
39
+
40
+    <symbol viewBox="0 0 20 20" id="locked">
41
+      <path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z"/>
42
+    </symbol>
43
+
44
+    <symbol viewBox="0 0 20 20" id="close">
45
+      <path d="M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z"/>
46
+    </symbol>
47
+
48
+    <symbol viewBox="0 0 20 20" id="large-arrow">
49
+      <path d="M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z"/>
50
+    </symbol>
51
+
52
+    <symbol viewBox="0 0 20 20" id="large-arrow-down">
53
+      <path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"/>
54
+    </symbol>
55
+
56
+
57
+    <symbol viewBox="0 0 24 24" id="jump-to">
58
+      <path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
59
+    </symbol>
60
+
61
+    <symbol viewBox="0 0 24 24" id="expand">
62
+      <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/>
63
+    </symbol>
64
+
65
+  </defs>
66
+</svg>
67
+
68
+<div id="swagger-ui"></div>
69
+
70
+<script src="./swagger-ui-bundle.js"> </script>
71
+<script src="./swagger-ui-standalone-preset.js"> </script>
72
+<script>
73
+window.onload = function() {
74
+  
75
+  // Build a system
76
+  const ui = SwaggerUIBundle({
77
+    url: "{{ spec_uri }}",
78
+    dom_id: '#swagger-ui',
79
+    deepLinking: true,
80
+    presets: [
81
+      SwaggerUIBundle.presets.apis,
82
+      SwaggerUIStandalonePreset
83
+    ],
84
+    plugins: [
85
+      SwaggerUIBundle.plugins.DownloadUrl
86
+    ],
87
+    layout: "StandaloneLayout"
88
+  })
89
+
90
+  window.ui = ui
91
+}
92
+</script>
93
+</body>
94
+
95
+</html>

+ 94 - 0
hapic/static/swaggerui/spec.yml View File

@@ -0,0 +1,94 @@
1
+definitions:
2
+  DefaultErrorBuilder:
3
+    properties:
4
+      code:
5
+        type: string
6
+        x-nullable: true
7
+      details:
8
+        type: object
9
+      message:
10
+        type: string
11
+    required:
12
+    - message
13
+    type: object
14
+  HelloJsonSchema:
15
+    properties:
16
+      color:
17
+        minLength: 3
18
+        type: string
19
+    required:
20
+    - color
21
+    type: object
22
+  HelloResponseSchema:
23
+    properties:
24
+      color:
25
+        type: string
26
+      name:
27
+        type: string
28
+      sentence:
29
+        type: string
30
+    required:
31
+    - name
32
+    - sentence
33
+    type: object
34
+info:
35
+  description: ''
36
+  title: ''
37
+  version: 1.0.0
38
+parameters: {}
39
+paths:
40
+  /hello/{name}:
41
+    get:
42
+      description: "my endpoint hello\n        ---\n        get:\n            description:\
43
+        \ my description\n            parameters:\n                - in: \"path\"\n\
44
+        \                  description: \"hello\"\n                  name: \"name\"\
45
+        \n                  type: \"string\"\n            responses:\n           \
46
+        \     200:\n                    description: A pet to be returned\n      \
47
+        \              schema: HelloResponseSchema"
48
+      parameters:
49
+      - in: path
50
+        name: name
51
+        required: true
52
+        type: string
53
+      - in: query
54
+        name: alive
55
+        required: false
56
+        type: boolean
57
+      responses:
58
+        '200':
59
+          description: '200'
60
+          schema:
61
+            $ref: '#/definitions/HelloResponseSchema'
62
+        '400':
63
+          description: '400'
64
+          schema:
65
+            $ref: '#/definitions/DefaultErrorBuilder'
66
+    post:
67
+      parameters:
68
+      - in: body
69
+        name: body
70
+        schema:
71
+          $ref: '#/definitions/HelloJsonSchema'
72
+      - in: path
73
+        name: name
74
+        required: true
75
+        type: string
76
+      responses:
77
+        '200':
78
+          description: '200'
79
+          schema:
80
+            $ref: '#/definitions/HelloResponseSchema'
81
+  /hello3/{name}:
82
+    get:
83
+      parameters:
84
+      - in: path
85
+        name: name
86
+        required: true
87
+        type: string
88
+      responses:
89
+        '200':
90
+          description: '200'
91
+          schema:
92
+            $ref: '#/definitions/HelloResponseSchema'
93
+swagger: '2.0'
94
+tags: []

File diff suppressed because it is too large
+ 87 - 0
hapic/static/swaggerui/swagger-ui-bundle.js


File diff suppressed because it is too large
+ 13 - 0
hapic/static/swaggerui/swagger-ui-standalone-preset.js


File diff suppressed because it is too large
+ 2 - 0
hapic/static/swaggerui/swagger-ui.css


File diff suppressed because it is too large
+ 8 - 0
hapic/static/swaggerui/swagger-ui.js


+ 2 - 1
tests/base.py View File

@@ -48,8 +48,9 @@ class MyContext(BottleContext):
48 48
 
49 49
     def get_response(
50 50
         self,
51
-        response: dict,
51
+        response: str,
52 52
         http_code: int,
53
+        mimetype: str='application/json',
53 54
     ) -> typing.Any:
54 55
         return {
55 56
             'original_response': response,