]>
Commit | Line | Data |
---|---|---|
11fdf7f2 TL |
1 | # -*- coding: utf-8 -*- |
2 | from __future__ import absolute_import | |
3 | ||
9f95a23c | 4 | import logging |
f67539c2 | 5 | from typing import Any, Dict, List, Union |
11fdf7f2 | 6 | |
f67539c2 | 7 | import cherrypy |
11fdf7f2 | 8 | |
f67539c2 TL |
9 | from .. import DEFAULT_VERSION, mgr |
10 | from ..api.doc import Schema, SchemaInput, SchemaType | |
18d92ca7 | 11 | from . import ENDPOINT_MAP, BaseController, Controller, Endpoint |
11fdf7f2 | 12 | |
f67539c2 | 13 | NO_DESCRIPTION_AVAILABLE = "*No description available*" |
11fdf7f2 | 14 | |
9f95a23c TL |
15 | logger = logging.getLogger('controllers.docs') |
16 | ||
17 | ||
11fdf7f2 TL |
18 | @Controller('/docs', secure=False) |
19 | class Docs(BaseController): | |
20 | ||
21 | @classmethod | |
22 | def _gen_tags(cls, all_endpoints): | |
23 | """ Generates a list of all tags and corresponding descriptions. """ | |
24 | # Scenarios to consider: | |
25 | # * Intentionally make up a new tag name at controller => New tag name displayed. | |
26 | # * Misspell or make up a new tag name at endpoint => Neither tag or endpoint displayed. | |
27 | # * Misspell tag name at controller (when referring to another controller) => | |
28 | # Tag displayed but no endpoints assigned | |
29 | # * Description for a tag added at multiple locations => Only one description displayed. | |
30 | list_of_ctrl = set() | |
31 | for endpoints in ENDPOINT_MAP.values(): | |
32 | for endpoint in endpoints: | |
33 | if endpoint.is_api or all_endpoints: | |
34 | list_of_ctrl.add(endpoint.ctrl) | |
35 | ||
f6b5b4d7 | 36 | tag_map: Dict[str, str] = {} |
11fdf7f2 TL |
37 | for ctrl in list_of_ctrl: |
38 | tag_name = ctrl.__name__ | |
39 | tag_descr = "" | |
40 | if hasattr(ctrl, 'doc_info'): | |
41 | if ctrl.doc_info['tag']: | |
42 | tag_name = ctrl.doc_info['tag'] | |
43 | tag_descr = ctrl.doc_info['tag_descr'] | |
9f95a23c TL |
44 | if tag_name not in tag_map or not tag_map[tag_name]: |
45 | tag_map[tag_name] = tag_descr | |
11fdf7f2 | 46 | |
f67539c2 | 47 | tags = [{'name': k, 'description': v if v else NO_DESCRIPTION_AVAILABLE} |
9f95a23c | 48 | for k, v in tag_map.items()] |
11fdf7f2 TL |
49 | tags.sort(key=lambda e: e['name']) |
50 | return tags | |
51 | ||
52 | @classmethod | |
53 | def _get_tag(cls, endpoint): | |
54 | """ Returns the name of a tag to assign to a path. """ | |
55 | ctrl = endpoint.ctrl | |
56 | func = endpoint.func | |
57 | tag = ctrl.__name__ | |
58 | if hasattr(func, 'doc_info') and func.doc_info['tag']: | |
59 | tag = func.doc_info['tag'] | |
60 | elif hasattr(ctrl, 'doc_info') and ctrl.doc_info['tag']: | |
61 | tag = ctrl.doc_info['tag'] | |
62 | return tag | |
63 | ||
64 | @classmethod | |
65 | def _gen_type(cls, param): | |
66 | # pylint: disable=too-many-return-statements | |
67 | """ | |
68 | Generates the type of parameter based on its name and default value, | |
69 | using very simple heuristics. | |
70 | Used if type is not explicitly defined. | |
71 | """ | |
72 | param_name = param['name'] | |
73 | def_value = param['default'] if 'default' in param else None | |
74 | if param_name.startswith("is_"): | |
f67539c2 | 75 | return str(SchemaType.BOOLEAN) |
11fdf7f2 | 76 | if "size" in param_name: |
f67539c2 | 77 | return str(SchemaType.INTEGER) |
11fdf7f2 | 78 | if "count" in param_name: |
f67539c2 | 79 | return str(SchemaType.INTEGER) |
11fdf7f2 | 80 | if "num" in param_name: |
f67539c2 | 81 | return str(SchemaType.INTEGER) |
11fdf7f2 | 82 | if isinstance(def_value, bool): |
f67539c2 | 83 | return str(SchemaType.BOOLEAN) |
11fdf7f2 | 84 | if isinstance(def_value, int): |
f67539c2 TL |
85 | return str(SchemaType.INTEGER) |
86 | return str(SchemaType.STRING) | |
11fdf7f2 TL |
87 | |
88 | @classmethod | |
89 | # isinstance doesn't work: input is always <type 'type'>. | |
90 | def _type_to_str(cls, type_as_type): | |
91 | """ Used if type is explicitly defined. """ | |
92 | if type_as_type is str: | |
f67539c2 | 93 | type_as_str = str(SchemaType.STRING) |
11fdf7f2 | 94 | elif type_as_type is int: |
f67539c2 | 95 | type_as_str = str(SchemaType.INTEGER) |
11fdf7f2 | 96 | elif type_as_type is bool: |
f67539c2 | 97 | type_as_str = str(SchemaType.BOOLEAN) |
11fdf7f2 | 98 | elif type_as_type is list or type_as_type is tuple: |
f67539c2 | 99 | type_as_str = str(SchemaType.ARRAY) |
11fdf7f2 | 100 | elif type_as_type is float: |
f67539c2 | 101 | type_as_str = str(SchemaType.NUMBER) |
11fdf7f2 | 102 | else: |
f67539c2 | 103 | type_as_str = str(SchemaType.OBJECT) |
11fdf7f2 TL |
104 | return type_as_str |
105 | ||
106 | @classmethod | |
107 | def _add_param_info(cls, parameters, p_info): | |
108 | # Cases to consider: | |
109 | # * Parameter name (if not nested) misspelt in decorator => parameter not displayed | |
110 | # * Sometimes a parameter is used for several endpoints (e.g. fs_id in CephFS). | |
111 | # Currently, there is no possibility of reuse. Should there be? | |
112 | # But what if there are two parameters with same name but different functionality? | |
113 | """ | |
114 | Adds explicitly described information for parameters of an endpoint. | |
115 | ||
116 | There are two cases: | |
117 | * Either the parameter in p_info corresponds to an endpoint parameter. Implicit information | |
118 | has higher priority, so only information that doesn't already exist is added. | |
119 | * Or the parameter in p_info describes a nested parameter inside an endpoint parameter. | |
120 | In that case there is no implicit information at all so all explicitly described info needs | |
121 | to be added. | |
122 | """ | |
123 | for p in p_info: | |
124 | if not p['nested']: | |
125 | for parameter in parameters: | |
126 | if p['name'] == parameter['name']: | |
127 | parameter['type'] = p['type'] | |
128 | parameter['description'] = p['description'] | |
129 | if 'nested_params' in p: | |
130 | parameter['nested_params'] = cls._add_param_info([], p['nested_params']) | |
131 | else: | |
132 | nested_p = { | |
133 | 'name': p['name'], | |
134 | 'type': p['type'], | |
135 | 'description': p['description'], | |
136 | 'required': p['required'], | |
137 | } | |
138 | if 'default' in p: | |
139 | nested_p['default'] = p['default'] | |
140 | if 'nested_params' in p: | |
141 | nested_p['nested_params'] = cls._add_param_info([], p['nested_params']) | |
142 | parameters.append(nested_p) | |
143 | ||
144 | return parameters | |
145 | ||
146 | @classmethod | |
f67539c2 | 147 | def _gen_schema_for_content(cls, params: List[Any]) -> Dict[str, Any]: |
11fdf7f2 TL |
148 | """ |
149 | Generates information to the content-object in OpenAPI Spec. | |
150 | Used to for request body and responses. | |
151 | """ | |
152 | required_params = [] | |
153 | properties = {} | |
f67539c2 TL |
154 | schema_type = SchemaType.OBJECT |
155 | if isinstance(params, SchemaInput): | |
156 | schema_type = params.type | |
157 | params = params.params | |
11fdf7f2 TL |
158 | |
159 | for param in params: | |
160 | if param['required']: | |
161 | required_params.append(param['name']) | |
162 | ||
163 | props = {} | |
164 | if 'type' in param: | |
165 | props['type'] = cls._type_to_str(param['type']) | |
166 | if 'nested_params' in param: | |
f67539c2 | 167 | if props['type'] == str(SchemaType.ARRAY): # dict in array |
11fdf7f2 TL |
168 | props['items'] = cls._gen_schema_for_content(param['nested_params']) |
169 | else: # dict in dict | |
170 | props = cls._gen_schema_for_content(param['nested_params']) | |
f67539c2 TL |
171 | elif props['type'] == str(SchemaType.OBJECT): # e.g. [int] |
172 | props['type'] = str(SchemaType.ARRAY) | |
11fdf7f2 TL |
173 | props['items'] = {'type': cls._type_to_str(param['type'][0])} |
174 | else: | |
175 | props['type'] = cls._gen_type(param) | |
176 | if 'description' in param: | |
177 | props['description'] = param['description'] | |
178 | if 'default' in param: | |
179 | props['default'] = param['default'] | |
180 | properties[param['name']] = props | |
181 | ||
f67539c2 TL |
182 | schema = Schema(schema_type=schema_type, properties=properties, |
183 | required=required_params) | |
184 | ||
185 | return schema.as_dict() | |
11fdf7f2 TL |
186 | |
187 | @classmethod | |
b3b6e05e | 188 | def _gen_responses(cls, method, resp_object=None, version=None): |
f6b5b4d7 | 189 | resp: Dict[str, Dict[str, Union[str, Any]]] = { |
11fdf7f2 TL |
190 | '400': { |
191 | "description": "Operation exception. Please check the " | |
192 | "response body for details." | |
193 | }, | |
194 | '401': { | |
195 | "description": "Unauthenticated access. Please login first." | |
196 | }, | |
197 | '403': { | |
198 | "description": "Unauthorized access. Please check your " | |
199 | "permissions." | |
200 | }, | |
201 | '500': { | |
202 | "description": "Unexpected error. Please check the " | |
203 | "response body for the stack trace." | |
204 | } | |
205 | } | |
b3b6e05e TL |
206 | |
207 | if not version: | |
208 | version = DEFAULT_VERSION | |
209 | ||
11fdf7f2 | 210 | if method.lower() == 'get': |
f67539c2 | 211 | resp['200'] = {'description': "OK", |
b3b6e05e | 212 | 'content': {'application/vnd.ceph.api.v{}+json'.format(version): |
f67539c2 | 213 | {'type': 'object'}}} |
11fdf7f2 | 214 | if method.lower() == 'post': |
f67539c2 | 215 | resp['201'] = {'description': "Resource created.", |
b3b6e05e | 216 | 'content': {'application/vnd.ceph.api.v{}+json'.format(version): |
f67539c2 | 217 | {'type': 'object'}}} |
11fdf7f2 | 218 | if method.lower() == 'put': |
f67539c2 | 219 | resp['200'] = {'description': "Resource updated.", |
b3b6e05e | 220 | 'content': {'application/vnd.ceph.api.v{}+json'.format(version): |
f67539c2 | 221 | {'type': 'object'}}} |
11fdf7f2 | 222 | if method.lower() == 'delete': |
f67539c2 | 223 | resp['204'] = {'description': "Resource deleted.", |
b3b6e05e | 224 | 'content': {'application/vnd.ceph.api.v{}+json'.format(version): |
f67539c2 | 225 | {'type': 'object'}}} |
11fdf7f2 TL |
226 | if method.lower() in ['post', 'put', 'delete']: |
227 | resp['202'] = {'description': "Operation is still executing." | |
f67539c2 | 228 | " Please check the task queue.", |
b3b6e05e | 229 | 'content': {'application/vnd.ceph.api.v{}+json'.format(version): |
f67539c2 | 230 | {'type': 'object'}}} |
11fdf7f2 TL |
231 | |
232 | if resp_object: | |
233 | for status_code, response_body in resp_object.items(): | |
f67539c2 TL |
234 | if status_code in resp: |
235 | resp[status_code].update({ | |
236 | 'content': { | |
b3b6e05e | 237 | 'application/vnd.ceph.api.v{}+json'.format(version): { |
f67539c2 | 238 | 'schema': cls._gen_schema_for_content(response_body)}}}) |
11fdf7f2 TL |
239 | |
240 | return resp | |
241 | ||
242 | @classmethod | |
243 | def _gen_params(cls, params, location): | |
244 | parameters = [] | |
245 | for param in params: | |
246 | if 'type' in param: | |
247 | _type = cls._type_to_str(param['type']) | |
248 | else: | |
249 | _type = cls._gen_type(param) | |
11fdf7f2 TL |
250 | res = { |
251 | 'name': param['name'], | |
252 | 'in': location, | |
253 | 'schema': { | |
254 | 'type': _type | |
255 | }, | |
11fdf7f2 | 256 | } |
f67539c2 TL |
257 | if param.get('description'): |
258 | res['description'] = param['description'] | |
11fdf7f2 TL |
259 | if param['required']: |
260 | res['required'] = True | |
261 | elif param['default'] is None: | |
262 | res['allowEmptyValue'] = True | |
263 | else: | |
264 | res['default'] = param['default'] | |
265 | parameters.append(res) | |
266 | ||
267 | return parameters | |
268 | ||
269 | @classmethod | |
b3b6e05e | 270 | def gen_paths(cls, all_endpoints): |
f67539c2 | 271 | # pylint: disable=R0912 |
9f95a23c | 272 | method_order = ['get', 'post', 'put', 'delete'] |
11fdf7f2 TL |
273 | paths = {} |
274 | for path, endpoints in sorted(list(ENDPOINT_MAP.items()), | |
275 | key=lambda p: p[0]): | |
276 | methods = {} | |
277 | skip = False | |
278 | ||
279 | endpoint_list = sorted(endpoints, key=lambda e: | |
9f95a23c | 280 | method_order.index(e.method.lower())) |
11fdf7f2 TL |
281 | for endpoint in endpoint_list: |
282 | if not endpoint.is_api and not all_endpoints: | |
283 | skip = True | |
284 | break | |
285 | ||
286 | method = endpoint.method | |
287 | func = endpoint.func | |
288 | ||
f67539c2 | 289 | summary = '' |
b3b6e05e | 290 | version = '' |
11fdf7f2 TL |
291 | resp = {} |
292 | p_info = [] | |
b3b6e05e TL |
293 | |
294 | if hasattr(func, '__method_map_method__'): | |
295 | version = func.__method_map_method__['version'] | |
296 | ||
297 | elif hasattr(func, '__resource_method__'): | |
298 | version = func.__resource_method__['version'] | |
299 | ||
300 | elif hasattr(func, '__collection_method__'): | |
301 | version = func.__collection_method__['version'] | |
302 | ||
11fdf7f2 TL |
303 | if hasattr(func, 'doc_info'): |
304 | if func.doc_info['summary']: | |
305 | summary = func.doc_info['summary'] | |
306 | resp = func.doc_info['response'] | |
307 | p_info = func.doc_info['parameters'] | |
308 | params = [] | |
309 | if endpoint.path_params: | |
310 | params.extend( | |
311 | cls._gen_params( | |
312 | cls._add_param_info(endpoint.path_params, p_info), 'path')) | |
313 | if endpoint.query_params: | |
314 | params.extend( | |
315 | cls._gen_params( | |
316 | cls._add_param_info(endpoint.query_params, p_info), 'query')) | |
317 | ||
318 | methods[method.lower()] = { | |
319 | 'tags': [cls._get_tag(endpoint)], | |
11fdf7f2 TL |
320 | 'description': func.__doc__, |
321 | 'parameters': params, | |
b3b6e05e | 322 | 'responses': cls._gen_responses(method, resp, version) |
11fdf7f2 | 323 | } |
f67539c2 TL |
324 | if summary: |
325 | methods[method.lower()]['summary'] = summary | |
11fdf7f2 TL |
326 | |
327 | if method.lower() in ['post', 'put']: | |
328 | if endpoint.body_params: | |
329 | body_params = cls._add_param_info(endpoint.body_params, p_info) | |
330 | methods[method.lower()]['requestBody'] = { | |
331 | 'content': { | |
332 | 'application/json': { | |
333 | 'schema': cls._gen_schema_for_content(body_params)}}} | |
334 | ||
f67539c2 TL |
335 | if endpoint.query_params: |
336 | query_params = cls._add_param_info(endpoint.query_params, p_info) | |
337 | methods[method.lower()]['requestBody'] = { | |
338 | 'content': { | |
339 | 'application/json': { | |
340 | 'schema': cls._gen_schema_for_content(query_params)}}} | |
341 | ||
11fdf7f2 TL |
342 | if endpoint.is_secure: |
343 | methods[method.lower()]['security'] = [{'jwt': []}] | |
344 | ||
345 | if not skip: | |
f6b5b4d7 | 346 | paths[path] = methods |
11fdf7f2 TL |
347 | |
348 | return paths | |
349 | ||
f67539c2 TL |
350 | @classmethod |
351 | def _gen_spec(cls, all_endpoints=False, base_url="", offline=False): | |
11fdf7f2 TL |
352 | if all_endpoints: |
353 | base_url = "" | |
354 | ||
f67539c2 | 355 | host = cherrypy.request.base.split('://', 1)[1] if not offline else 'example.com' |
9f95a23c | 356 | logger.debug("Host: %s", host) |
11fdf7f2 | 357 | |
b3b6e05e | 358 | paths = cls.gen_paths(all_endpoints) |
11fdf7f2 TL |
359 | |
360 | if not base_url: | |
361 | base_url = "/" | |
362 | ||
f67539c2 | 363 | scheme = 'https' if offline or mgr.get_localized_module_option('ssl') else 'http' |
11fdf7f2 TL |
364 | |
365 | spec = { | |
366 | 'openapi': "3.0.0", | |
367 | 'info': { | |
f67539c2 | 368 | 'description': "This is the official Ceph REST API", |
11fdf7f2 | 369 | 'version': "v1", |
f67539c2 | 370 | 'title': "Ceph REST API" |
11fdf7f2 TL |
371 | }, |
372 | 'host': host, | |
373 | 'basePath': base_url, | |
f67539c2 TL |
374 | 'servers': [{'url': "{}{}".format( |
375 | cherrypy.request.base if not offline else '', | |
376 | base_url)}], | |
377 | 'tags': cls._gen_tags(all_endpoints), | |
11fdf7f2 TL |
378 | 'schemes': [scheme], |
379 | 'paths': paths, | |
380 | 'components': { | |
381 | 'securitySchemes': { | |
382 | 'jwt': { | |
383 | 'type': 'http', | |
384 | 'scheme': 'bearer', | |
385 | 'bearerFormat': 'JWT' | |
386 | } | |
387 | } | |
388 | } | |
389 | } | |
390 | ||
391 | return spec | |
392 | ||
f67539c2 | 393 | @Endpoint(path="api.json", version=None) |
11fdf7f2 | 394 | def api_json(self): |
f6b5b4d7 | 395 | return self._gen_spec(False, "/") |
11fdf7f2 | 396 | |
f67539c2 | 397 | @Endpoint(path="api-all.json", version=None) |
11fdf7f2 | 398 | def api_all_json(self): |
f6b5b4d7 | 399 | return self._gen_spec(True, "/") |
11fdf7f2 | 400 | |
18d92ca7 | 401 | def _swagger_ui_page(self, all_endpoints=False): |
11fdf7f2 TL |
402 | base = cherrypy.request.base |
403 | if all_endpoints: | |
404 | spec_url = "{}/docs/api-all.json".format(base) | |
405 | else: | |
406 | spec_url = "{}/docs/api.json".format(base) | |
407 | ||
11fdf7f2 TL |
408 | page = """ |
409 | <!DOCTYPE html> | |
410 | <html> | |
411 | <head> | |
412 | <meta charset="UTF-8"> | |
413 | <meta name="referrer" content="no-referrer" /> | |
11fdf7f2 | 414 | <link rel="stylesheet" type="text/css" |
9f95a23c | 415 | href="/swagger-ui.css" > |
11fdf7f2 TL |
416 | <style> |
417 | html | |
418 | {{ | |
419 | box-sizing: border-box; | |
420 | overflow: -moz-scrollbars-vertical; | |
421 | overflow-y: scroll; | |
422 | }} | |
423 | *, | |
424 | *:before, | |
425 | *:after | |
426 | {{ | |
427 | box-sizing: inherit; | |
428 | }} | |
429 | body {{ | |
430 | margin:0; | |
431 | background: #fafafa; | |
432 | }} | |
433 | </style> | |
434 | </head> | |
435 | <body> | |
436 | <div id="swagger-ui"></div> | |
9f95a23c | 437 | <script src="/swagger-ui-bundle.js"> |
11fdf7f2 TL |
438 | </script> |
439 | <script> | |
440 | window.onload = function() {{ | |
441 | const ui = SwaggerUIBundle({{ | |
442 | url: '{}', | |
443 | dom_id: '#swagger-ui', | |
444 | presets: [ | |
445 | SwaggerUIBundle.presets.apis | |
446 | ], | |
447 | layout: "BaseLayout" | |
11fdf7f2 TL |
448 | }}) |
449 | window.ui = ui | |
450 | }} | |
451 | </script> | |
452 | </body> | |
453 | </html> | |
18d92ca7 | 454 | """.format(spec_url) |
11fdf7f2 TL |
455 | |
456 | return page | |
457 | ||
f67539c2 | 458 | @Endpoint(json_response=False, version=None) |
11fdf7f2 TL |
459 | def __call__(self, all_endpoints=False): |
460 | return self._swagger_ui_page(all_endpoints) | |
461 | ||
f67539c2 TL |
462 | |
463 | if __name__ == "__main__": | |
464 | import sys | |
465 | ||
466 | import yaml | |
467 | ||
468 | from . import generate_routes | |
469 | ||
470 | def fix_null_descr(obj): | |
471 | """ | |
472 | A hot fix for errors caused by null description values when generating | |
473 | static documentation: better fix would be default values in source | |
474 | to be 'None' strings: however, decorator changes didn't resolve | |
475 | """ | |
476 | return {k: fix_null_descr(v) for k, v in obj.items() if v is not None} \ | |
477 | if isinstance(obj, dict) else obj | |
478 | ||
479 | generate_routes("/api") | |
480 | try: | |
481 | with open(sys.argv[1], 'w') as f: | |
482 | # pylint: disable=protected-access | |
483 | yaml.dump( | |
484 | fix_null_descr(Docs._gen_spec(all_endpoints=False, base_url="/", offline=True)), | |
485 | f) | |
486 | except IndexError: | |
487 | sys.exit("Output file name missing; correct syntax is: `cmd <file.yml>`") | |
488 | except IsADirectoryError: | |
489 | sys.exit("Specified output is a directory; correct syntax is: `cmd <file.yml>`") |