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