]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: clean-up controllers
authorErnesto Puerta <epuertat@redhat.com>
Tue, 7 Sep 2021 15:07:48 +0000 (17:07 +0200)
committerAvan Thakkar <athakkar@redhat.com>
Wed, 6 Oct 2021 18:14:18 +0000 (23:44 +0530)
Fixes: https://tracker.ceph.com/issues/52589
Signed-off-by: Ernesto Puerta <epuertat@redhat.com>
60 files changed:
src/pybind/mgr/dashboard/CMakeLists.txt
src/pybind/mgr/dashboard/controllers/__init__.py
src/pybind/mgr/dashboard/controllers/_api_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_auth.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_base_controller.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_docs.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_endpoint.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_helpers.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_permissions.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_rest_controller.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_task.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/_ui_router.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/controllers/cluster_configuration.py
src/pybind/mgr/dashboard/controllers/crush_rule.py
src/pybind/mgr/dashboard/controllers/docs.py
src/pybind/mgr/dashboard/controllers/erasure_code_profile.py
src/pybind/mgr/dashboard/controllers/feedback.py
src/pybind/mgr/dashboard/controllers/frontend_logging.py
src/pybind/mgr/dashboard/controllers/grafana.py
src/pybind/mgr/dashboard/controllers/health.py
src/pybind/mgr/dashboard/controllers/home.py
src/pybind/mgr/dashboard/controllers/host.py
src/pybind/mgr/dashboard/controllers/iscsi.py
src/pybind/mgr/dashboard/controllers/logs.py
src/pybind/mgr/dashboard/controllers/mgr_modules.py
src/pybind/mgr/dashboard/controllers/monitor.py
src/pybind/mgr/dashboard/controllers/nfsganesha.py
src/pybind/mgr/dashboard/controllers/orchestrator.py
src/pybind/mgr/dashboard/controllers/osd.py
src/pybind/mgr/dashboard/controllers/perf_counters.py
src/pybind/mgr/dashboard/controllers/pool.py
src/pybind/mgr/dashboard/controllers/prometheus.py
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/controllers/role.py
src/pybind/mgr/dashboard/controllers/saml2.py
src/pybind/mgr/dashboard/controllers/service.py
src/pybind/mgr/dashboard/controllers/settings.py
src/pybind/mgr/dashboard/controllers/summary.py
src/pybind/mgr/dashboard/controllers/task.py
src/pybind/mgr/dashboard/controllers/telemetry.py
src/pybind/mgr/dashboard/controllers/user.py
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/plugins/feature_toggles.py
src/pybind/mgr/dashboard/plugins/motd.py
src/pybind/mgr/dashboard/tests/__init__.py
src/pybind/mgr/dashboard/tests/test_api_auditing.py
src/pybind/mgr/dashboard/tests/test_controllers.py
src/pybind/mgr/dashboard/tests/test_docs.py
src/pybind/mgr/dashboard/tests/test_exceptions.py
src/pybind/mgr/dashboard/tests/test_feature_toggles.py
src/pybind/mgr/dashboard/tests/test_host.py
src/pybind/mgr/dashboard/tests/test_rest_tasks.py
src/pybind/mgr/dashboard/tests/test_tools.py
src/pybind/mgr/dashboard/tests/test_versioning.py

index 40bb66650d2445255569b56646c7e4d3be787f74..81bb9dd1bb23a066f2b478f864d4df2d45fa8ade 100644 (file)
@@ -9,7 +9,10 @@ if(WITH_MGR_DASHBOARD_FRONTEND)
   add_subdirectory(frontend)
   if(WITH_TESTS)
     include(AddCephTest)
-    add_tox_test(mgr-dashboard TOX_ENVS py3 lint check openapi-check)
+    add_tox_test(mgr-dashboard-py3 TOX_ENVS py3)
+    add_tox_test(mgr-dashboard-lint TOX_ENVS lint)
+    add_tox_test(mgr-dashboard-check TOX_ENVS check)
+    add_tox_test(mgr-dashboard-openapi TOX_ENVS openapi-check)
   endif()
 else()
   # prebuilt
index 25fd383fc1574472d18236c3a84c6ca354ab9286..7a5b090ee461e71d7b90ff890db5beaad610c088 100755 (executable)
-# -*- coding: utf-8 -*-
-# pylint: disable=protected-access,too-many-branches,too-many-lines
-
-import collections
-import importlib
-import inspect
-import json
-import logging
-import os
-import pkgutil
-import re
-import sys
-from functools import wraps
-from urllib.parse import unquote
-
-# pylint: disable=wrong-import-position
-import cherrypy
-# pylint: disable=import-error
-from ceph_argparse import ArgumentFormat
-
-from ..api.doc import SchemaInput, SchemaType
-from ..exceptions import DashboardException, PermissionNotValid, ScopeNotValid
-from ..plugins import PLUGIN_MANAGER
-from ..security import Permission, Scope
-from ..services.auth import AuthManager, JwtManager
-from ..tools import TaskManager, get_request_body_params, getargspec
-from ._version import APIVersion
-
-try:
-    from typing import Any, Dict, List, Optional, Tuple, Union
-except ImportError:
-    pass  # For typing only
-
-
-class EndpointDoc:  # noqa: N802
-    DICT_TYPE = Union[Dict[str, Any], Dict[int, Any]]
-
-    def __init__(self, description: str = "", group: str = "",
-                 parameters: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]] = None,
-                 responses: Optional[DICT_TYPE] = None) -> None:
-        self.description = description
-        self.group = group
-        self.parameters = parameters
-        self.responses = responses
-
-        self.validate_args()
-
-        if not self.parameters:
-            self.parameters = {}  # type: ignore
-
-        self.resp = {}
-        if self.responses:
-            for status_code, response_body in self.responses.items():
-                schema_input = SchemaInput()
-                schema_input.type = SchemaType.ARRAY if \
-                    isinstance(response_body, list) else SchemaType.OBJECT
-                schema_input.params = self._split_parameters(response_body)
-
-                self.resp[str(status_code)] = schema_input
-
-    def validate_args(self) -> None:
-        if not isinstance(self.description, str):
-            raise Exception("%s has been called with a description that is not a string: %s"
-                            % (EndpointDoc.__name__, self.description))
-        if not isinstance(self.group, str):
-            raise Exception("%s has been called with a groupname that is not a string: %s"
-                            % (EndpointDoc.__name__, self.group))
-        if self.parameters and not isinstance(self.parameters, dict):
-            raise Exception("%s has been called with parameters that is not a dict: %s"
-                            % (EndpointDoc.__name__, self.parameters))
-        if self.responses and not isinstance(self.responses, dict):
-            raise Exception("%s has been called with responses that is not a dict: %s"
-                            % (EndpointDoc.__name__, self.responses))
-
-    def _split_param(self, name: str, p_type: Union[type, DICT_TYPE, List[Any], Tuple[Any, ...]],
-                     description: str, optional: bool = False, default_value: Any = None,
-                     nested: bool = False) -> Dict[str, Any]:
-        param = {
-            'name': name,
-            'description': description,
-            'required': not optional,
-            'nested': nested,
-        }
-        if default_value:
-            param['default'] = default_value
-        if isinstance(p_type, type):
-            param['type'] = p_type
-        else:
-            nested_params = self._split_parameters(p_type, nested=True)
-            if nested_params:
-                param['type'] = type(p_type)
-                param['nested_params'] = nested_params
-            else:
-                param['type'] = p_type
-        return param
-
-    #  Optional must be set to True in order to set default value and parameters format must be:
-    # 'name: (type or nested parameters, description, [optional], [default value])'
-    def _split_dict(self, data: DICT_TYPE, nested: bool) -> List[Any]:
-        splitted = []
-        for name, props in data.items():
-            if isinstance(name, str) and isinstance(props, tuple):
-                if len(props) == 2:
-                    param = self._split_param(name, props[0], props[1], nested=nested)
-                elif len(props) == 3:
-                    param = self._split_param(
-                        name, props[0], props[1], optional=props[2], nested=nested)
-                if len(props) == 4:
-                    param = self._split_param(name, props[0], props[1], props[2], props[3], nested)
-                splitted.append(param)
-            else:
-                raise Exception(
-                    """Parameter %s in %s has not correct format. Valid formats are:
-                    <name>: (<type>, <description>, [optional], [default value])
-                    <name>: (<[type]>, <description>, [optional], [default value])
-                    <name>: (<[nested parameters]>, <description>, [optional], [default value])
-                    <name>: (<{nested parameters}>, <description>, [optional], [default value])"""
-                    % (name, EndpointDoc.__name__))
-        return splitted
-
-    def _split_list(self, data: Union[List[Any], Tuple[Any, ...]], nested: bool) -> List[Any]:
-        splitted = []  # type: List[Any]
-        for item in data:
-            splitted.extend(self._split_parameters(item, nested))
-        return splitted
-
-    # nested = True means parameters are inside a dict or array
-    def _split_parameters(self, data: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]],
-                          nested: bool = False) -> List[Any]:
-        param_list = []  # type: List[Any]
-        if isinstance(data, dict):
-            param_list.extend(self._split_dict(data, nested))
-        elif isinstance(data, (list, tuple)):
-            param_list.extend(self._split_list(data, True))
-        return param_list
-
-    def __call__(self, func: Any) -> Any:
-        func.doc_info = {
-            'summary': self.description,
-            'tag': self.group,
-            'parameters': self._split_parameters(self.parameters),
-            'response': self.resp
-        }
-        return func
-
-
-class ControllerDoc(object):
-    def __init__(self, description="", group=""):
-        self.tag = group
-        self.tag_descr = description
-
-    def __call__(self, cls):
-        cls.doc_info = {
-            'tag': self.tag,
-            'tag_descr': self.tag_descr
-        }
-        return cls
-
-
-class Controller(object):
-    def __init__(self, path, base_url=None, security_scope=None, secure=True):
-        if security_scope and not Scope.valid_scope(security_scope):
-            raise ScopeNotValid(security_scope)
-        self.path = path
-        self.base_url = base_url
-        self.security_scope = security_scope
-        self.secure = secure
-
-        if self.path and self.path[0] != "/":
-            self.path = "/" + self.path
-
-        if self.base_url is None:
-            self.base_url = ""
-        elif self.base_url == "/":
-            self.base_url = ""
-
-        if self.base_url == "" and self.path == "":
-            self.base_url = "/"
-
-    def __call__(self, cls):
-        cls._cp_controller_ = True
-        cls._cp_path_ = "{}{}".format(self.base_url, self.path)
-        cls._security_scope = self.security_scope
-
-        config = {
-            'tools.dashboard_exception_handler.on': True,
-            'tools.authenticate.on': self.secure,
-        }
-        if not hasattr(cls, '_cp_config'):
-            cls._cp_config = {}
-        cls._cp_config.update(config)
-        return cls
-
-
-class ApiController(Controller):
-    def __init__(self, path, security_scope=None, secure=True):
-        super(ApiController, self).__init__(path, base_url="/api",
-                                            security_scope=security_scope,
-                                            secure=secure)
-
-    def __call__(self, cls):
-        cls = super(ApiController, self).__call__(cls)
-        cls._api_endpoint = True
-        return cls
-
-
-class UiApiController(Controller):
-    def __init__(self, path, security_scope=None, secure=True):
-        super(UiApiController, self).__init__(path, base_url="/ui-api",
-                                              security_scope=security_scope,
-                                              secure=secure)
-
-    def __call__(self, cls):
-        cls = super(UiApiController, self).__call__(cls)
-        cls._api_endpoint = False
-        return cls
-
-
-class Endpoint:
-
-    def __init__(self, method=None, path=None, path_params=None, query_params=None,  # noqa: N802
-                 json_response=True, proxy=False, xml=False,
-                 version: Optional[APIVersion] = APIVersion.DEFAULT):
-        if method is None:
-            method = 'GET'
-        elif not isinstance(method, str) or \
-                method.upper() not in ['GET', 'POST', 'DELETE', 'PUT']:
-            raise TypeError("Possible values for method are: 'GET', 'POST', "
-                            "'DELETE', or 'PUT'")
-
-        method = method.upper()
-
-        if method in ['GET', 'DELETE']:
-            if path_params is not None:
-                raise TypeError("path_params should not be used for {} "
-                                "endpoints. All function params are considered"
-                                " path parameters by default".format(method))
-
-        if path_params is None:
-            if method in ['POST', 'PUT']:
-                path_params = []
-
-        if query_params is None:
-            query_params = []
-
-        self.method = method
-        self.path = path
-        self.path_params = path_params
-        self.query_params = query_params
-        self.json_response = json_response
-        self.proxy = proxy
-        self.xml = xml
-        self.version = version
-
-    def __call__(self, func):
-        if self.method in ['POST', 'PUT']:
-            func_params = _get_function_params(func)
-            for param in func_params:
-                if param['name'] in self.path_params and not param['required']:
-                    raise TypeError("path_params can only reference "
-                                    "non-optional function parameters")
-
-        if func.__name__ == '__call__' and self.path is None:
-            e_path = ""
-        else:
-            e_path = self.path
-
-        if e_path is not None:
-            e_path = e_path.strip()
-            if e_path and e_path[0] != "/":
-                e_path = "/" + e_path
-            elif e_path == "/":
-                e_path = ""
-
-        func._endpoint = {
-            'method': self.method,
-            'path': e_path,
-            'path_params': self.path_params,
-            'query_params': self.query_params,
-            'json_response': self.json_response,
-            'proxy': self.proxy,
-            'xml': self.xml,
-            'version': self.version
-        }
-        return func
-
-
-def Proxy(path=None):  # noqa: N802
-    if path is None:
-        path = ""
-    elif path == "/":
-        path = ""
-    path += "/{path:.*}"
-    return Endpoint(path=path, proxy=True)
-
-
-def load_controllers():
-    logger = logging.getLogger('controller.load')
-    # setting sys.path properly when not running under the mgr
-    controllers_dir = os.path.dirname(os.path.realpath(__file__))
-    dashboard_dir = os.path.dirname(controllers_dir)
-    mgr_dir = os.path.dirname(dashboard_dir)
-    logger.debug("controllers_dir=%s", controllers_dir)
-    logger.debug("dashboard_dir=%s", dashboard_dir)
-    logger.debug("mgr_dir=%s", mgr_dir)
-    if mgr_dir not in sys.path:
-        sys.path.append(mgr_dir)
-
-    controllers = []
-    mods = [mod for _, mod, _ in pkgutil.iter_modules([controllers_dir])]
-    logger.debug("mods=%s", mods)
-    for mod_name in mods:
-        mod = importlib.import_module('.controllers.{}'.format(mod_name),
-                                      package='dashboard')
-        for _, cls in mod.__dict__.items():
-            # Controllers MUST be derived from the class BaseController.
-            if inspect.isclass(cls) and issubclass(cls, BaseController) and \
-                    hasattr(cls, '_cp_controller_'):
-                if cls._cp_path_.startswith(':'):
-                    # invalid _cp_path_ value
-                    logger.error("Invalid url prefix '%s' for controller '%s'",
-                                 cls._cp_path_, cls.__name__)
-                    continue
-                controllers.append(cls)
-
-    for clist in PLUGIN_MANAGER.hook.get_controllers() or []:
-        controllers.extend(clist)
-
-    return controllers
-
-
-ENDPOINT_MAP = collections.defaultdict(list)  # type: dict
-
-
-def generate_controller_routes(endpoint, mapper, base_url):
-    inst = endpoint.inst
-    ctrl_class = endpoint.ctrl
-
-    if endpoint.proxy:
-        conditions = None
-    else:
-        conditions = dict(method=[endpoint.method])
-
-    # base_url can be empty or a URL path that starts with "/"
-    # we will remove the trailing "/" if exists to help with the
-    # concatenation with the endpoint url below
-    if base_url.endswith("/"):
-        base_url = base_url[:-1]
-
-    endp_url = endpoint.url
-
-    if endp_url.find("/", 1) == -1:
-        parent_url = "{}{}".format(base_url, endp_url)
-    else:
-        parent_url = "{}{}".format(base_url, endp_url[:endp_url.find("/", 1)])
-
-    # parent_url might be of the form "/.../{...}" where "{...}" is a path parameter
-    # we need to remove the path parameter definition
-    parent_url = re.sub(r'(?:/\{[^}]+\})$', '', parent_url)
-    if not parent_url:  # root path case
-        parent_url = "/"
-
-    url = "{}{}".format(base_url, endp_url)
-
-    logger = logging.getLogger('controller')
-    logger.debug("Mapped [%s] to %s:%s restricted to %s",
-                 url, ctrl_class.__name__, endpoint.action,
-                 endpoint.method)
-
-    ENDPOINT_MAP[endpoint.url].append(endpoint)
-
-    name = ctrl_class.__name__ + ":" + endpoint.action
-    mapper.connect(name, url, controller=inst, action=endpoint.action,
-                   conditions=conditions)
-
-    # adding route with trailing slash
-    name += "/"
-    url += "/"
-    mapper.connect(name, url, controller=inst, action=endpoint.action,
-                   conditions=conditions)
-
-    return parent_url
-
-
-def generate_routes(url_prefix):
-    mapper = cherrypy.dispatch.RoutesDispatcher()
-    ctrls = load_controllers()
-
-    parent_urls = set()
-
-    endpoint_list = []
-    for ctrl in ctrls:
-        inst = ctrl()
-        for endpoint in ctrl.endpoints():
-            endpoint.inst = inst
-            endpoint_list.append(endpoint)
-
-    endpoint_list = sorted(endpoint_list, key=lambda e: e.url)
-    for endpoint in endpoint_list:
-        parent_urls.add(generate_controller_routes(endpoint, mapper,
-                                                   "{}".format(url_prefix)))
-
-    logger = logging.getLogger('controller')
-    logger.debug("list of parent paths: %s", parent_urls)
-    return mapper, parent_urls
-
-
-def json_error_page(status, message, traceback, version):
-    cherrypy.response.headers['Content-Type'] = 'application/json'
-    return json.dumps(dict(status=status, detail=message, traceback=traceback,
-                           version=version))
-
-
-def _get_function_params(func):
-    """
-    Retrieves the list of parameters declared in function.
-    Each parameter is represented as dict with keys:
-      * name (str): the name of the parameter
-      * required (bool): whether the parameter is required or not
-      * default (obj): the parameter's default value
-    """
-    fspec = getargspec(func)
-
-    func_params = []
-    nd = len(fspec.args) if not fspec.defaults else -len(fspec.defaults)
-    for param in fspec.args[1:nd]:
-        func_params.append({'name': param, 'required': True})
-
-    if fspec.defaults:
-        for param, val in zip(fspec.args[nd:], fspec.defaults):
-            func_params.append({
-                'name': param,
-                'required': False,
-                'default': val
-            })
-
-    return func_params
-
-
-class Task(object):
-    def __init__(self, name, metadata, wait_for=5.0, exception_handler=None):
-        self.name = name
-        if isinstance(metadata, list):
-            self.metadata = {e[1:-1]: e for e in metadata}
-        else:
-            self.metadata = metadata
-        self.wait_for = wait_for
-        self.exception_handler = exception_handler
-
-    def _gen_arg_map(self, func, args, kwargs):
-        arg_map = {}
-        params = _get_function_params(func)
-
-        args = args[1:]  # exclude self
-        for idx, param in enumerate(params):
-            if idx < len(args):
-                arg_map[param['name']] = args[idx]
-            else:
-                if param['name'] in kwargs:
-                    arg_map[param['name']] = kwargs[param['name']]
-                else:
-                    assert not param['required'], "{0} is required".format(param['name'])
-                    arg_map[param['name']] = param['default']
-
-            if param['name'] in arg_map:
-                # This is not a type error. We are using the index here.
-                arg_map[idx+1] = arg_map[param['name']]
-
-        return arg_map
-
-    def __call__(self, func):
-        @wraps(func)
-        def wrapper(*args, **kwargs):
-            arg_map = self._gen_arg_map(func, args, kwargs)
-            metadata = {}
-            for k, v in self.metadata.items():
-                if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}':
-                    param = v[1:-1]
-                    try:
-                        pos = int(param)
-                        metadata[k] = arg_map[pos]
-                    except ValueError:
-                        if param.find('.') == -1:
-                            metadata[k] = arg_map[param]
-                        else:
-                            path = param.split('.')
-                            metadata[k] = arg_map[path[0]]
-                            for i in range(1, len(path)):
-                                metadata[k] = metadata[k][path[i]]
-                else:
-                    metadata[k] = v
-            task = TaskManager.run(self.name, metadata, func, args, kwargs,
-                                   exception_handler=self.exception_handler)
-            try:
-                status, value = task.wait(self.wait_for)
-            except Exception as ex:
-                if task.ret_value:
-                    # exception was handled by task.exception_handler
-                    if 'status' in task.ret_value:
-                        status = task.ret_value['status']
-                    else:
-                        status = getattr(ex, 'status', 500)
-                    cherrypy.response.status = status
-                    return task.ret_value
-                raise ex
-            if status == TaskManager.VALUE_EXECUTING:
-                cherrypy.response.status = 202
-                return {'name': self.name, 'metadata': metadata}
-            return value
-        return wrapper
-
-
-class BaseController(object):
-    """
-    Base class for all controllers providing API endpoints.
-    """
-
-    class Endpoint(object):
-        """
-        An instance of this class represents an endpoint.
-        """
-
-        def __init__(self, ctrl, func):
-            self.ctrl = ctrl
-            self.inst = None
-            self.func = func
-
-            if not self.config['proxy']:
-                setattr(self.ctrl, func.__name__, self.function)
-
-        @property
-        def config(self):
-            func = self.func
-            while not hasattr(func, '_endpoint'):
-                if hasattr(func, "__wrapped__"):
-                    func = func.__wrapped__
-                else:
-                    return None
-            return func._endpoint
-
-        @property
-        def function(self):
-            return self.ctrl._request_wrapper(self.func, self.method,
-                                              self.config['json_response'],
-                                              self.config['xml'],
-                                              self.config['version'])
-
-        @property
-        def method(self):
-            return self.config['method']
-
-        @property
-        def proxy(self):
-            return self.config['proxy']
-
-        @property
-        def url(self):
-            ctrl_path = self.ctrl.get_path()
-            if ctrl_path == "/":
-                ctrl_path = ""
-            if self.config['path'] is not None:
-                url = "{}{}".format(ctrl_path, self.config['path'])
-            else:
-                url = "{}/{}".format(ctrl_path, self.func.__name__)
-
-            ctrl_path_params = self.ctrl.get_path_param_names(
-                self.config['path'])
-            path_params = [p['name'] for p in self.path_params
-                           if p['name'] not in ctrl_path_params]
-            path_params = ["{{{}}}".format(p) for p in path_params]
-            if path_params:
-                url += "/{}".format("/".join(path_params))
-
-            return url
-
-        @property
-        def action(self):
-            return self.func.__name__
-
-        @property
-        def path_params(self):
-            ctrl_path_params = self.ctrl.get_path_param_names(
-                self.config['path'])
-            func_params = _get_function_params(self.func)
-
-            if self.method in ['GET', 'DELETE']:
-                assert self.config['path_params'] is None
-
-                return [p for p in func_params if p['name'] in ctrl_path_params
-                        or (p['name'] not in self.config['query_params']
-                            and p['required'])]
-
-            # elif self.method in ['POST', 'PUT']:
-            return [p for p in func_params if p['name'] in ctrl_path_params
-                    or p['name'] in self.config['path_params']]
-
-        @property
-        def query_params(self):
-            if self.method in ['GET', 'DELETE']:
-                func_params = _get_function_params(self.func)
-                path_params = [p['name'] for p in self.path_params]
-                return [p for p in func_params if p['name'] not in path_params]
-
-            # elif self.method in ['POST', 'PUT']:
-            func_params = _get_function_params(self.func)
-            return [p for p in func_params
-                    if p['name'] in self.config['query_params']]
-
-        @property
-        def body_params(self):
-            func_params = _get_function_params(self.func)
-            path_params = [p['name'] for p in self.path_params]
-            query_params = [p['name'] for p in self.query_params]
-            return [p for p in func_params
-                    if p['name'] not in path_params
-                    and p['name'] not in query_params]
-
-        @property
-        def group(self):
-            return self.ctrl.__name__
-
-        @property
-        def is_api(self):
-            # changed from hasattr to getattr: some ui-based api inherit _api_endpoint
-            return getattr(self.ctrl, '_api_endpoint', False)
-
-        @property
-        def is_secure(self):
-            return self.ctrl._cp_config['tools.authenticate.on']
-
-        def __repr__(self):
-            return "Endpoint({}, {}, {})".format(self.url, self.method,
-                                                 self.action)
-
-    def __init__(self):
-        logger = logging.getLogger('controller')
-        logger.info('Initializing controller: %s -> %s',
-                    self.__class__.__name__, self._cp_path_)  # type: ignore
-        super(BaseController, self).__init__()
-
-    def _has_permissions(self, permissions, scope=None):
-        if not self._cp_config['tools.authenticate.on']:  # type: ignore
-            raise Exception("Cannot verify permission in non secured "
-                            "controllers")
-
-        if not isinstance(permissions, list):
-            permissions = [permissions]
-
-        if scope is None:
-            scope = getattr(self, '_security_scope', None)
-        if scope is None:
-            raise Exception("Cannot verify permissions without scope security"
-                            " defined")
-        username = JwtManager.LOCAL_USER.username
-        return AuthManager.authorize(username, scope, permissions)
-
-    @classmethod
-    def get_path_param_names(cls, path_extension=None):
-        if path_extension is None:
-            path_extension = ""
-        full_path = cls._cp_path_[1:] + path_extension  # type: ignore
-        path_params = []
-        for step in full_path.split('/'):
-            param = None
-            if not step:
-                continue
-            if step[0] == ':':
-                param = step[1:]
-            elif step[0] == '{' and step[-1] == '}':
-                param, _, _ = step[1:-1].partition(':')
-            if param:
-                path_params.append(param)
-        return path_params
-
-    @classmethod
-    def get_path(cls):
-        return cls._cp_path_  # type: ignore
-
-    @classmethod
-    def endpoints(cls):
-        """
-        This method iterates over all the methods decorated with ``@endpoint``
-        and creates an Endpoint object for each one of the methods.
-
-        :return: A list of endpoint objects
-        :rtype: list[BaseController.Endpoint]
-        """
-        result = []
-        for _, func in inspect.getmembers(cls, predicate=callable):
-            if hasattr(func, '_endpoint'):
-                result.append(cls.Endpoint(cls, func))
-        return result
-
-    @staticmethod
-    def _request_wrapper(func, method, json_response, xml,  # pylint: disable=unused-argument
-                         version: Optional[APIVersion]):
-        @wraps(func)
-        def inner(*args, **kwargs):
-            client_version = None
-            for key, value in kwargs.items():
-                if isinstance(value, str):
-                    kwargs[key] = unquote(value)
-
-            # Process method arguments.
-            params = get_request_body_params(cherrypy.request)
-            kwargs.update(params)
-
-            if version is not None:
-                try:
-                    client_version = APIVersion.from_mime_type(
-                        cherrypy.request.headers['Accept'])
-                except Exception:
-                    raise cherrypy.HTTPError(
-                        415, "Unable to find version in request header")
-
-                if version.supports(client_version):
-                    ret = func(*args, **kwargs)
-                else:
-                    raise cherrypy.HTTPError(
-                        415,
-                        f"Incorrect version: endpoint is '{version!s}', "
-                        f"client requested '{client_version!s}'"
-                    )
-            else:
-                ret = func(*args, **kwargs)
-            if isinstance(ret, bytes):
-                ret = ret.decode('utf-8')
-            if xml:
-                if version:
-                    cherrypy.response.headers['Content-Type'] = \
-                        version.to_mime_type(subtype='xml')
-                else:
-                    cherrypy.response.headers['Content-Type'] = 'application/xml'
-                return ret.encode('utf8')
-            if json_response:
-                if version:
-                    cherrypy.response.headers['Content-Type'] = \
-                        version.to_mime_type()
-                else:
-                    cherrypy.response.headers['Content-Type'] = 'application/json'
-                ret = json.dumps(ret).encode('utf8')
-            return ret
-        return inner
-
-    @property
-    def _request(self):
-        return self.Request(cherrypy.request)
-
-    class Request(object):
-        def __init__(self, cherrypy_req):
-            self._creq = cherrypy_req
-
-        @property
-        def scheme(self):
-            return self._creq.scheme
-
-        @property
-        def host(self):
-            base = self._creq.base
-            base = base[len(self.scheme)+3:]
-            return base[:base.find(":")] if ":" in base else base
-
-        @property
-        def port(self):
-            base = self._creq.base
-            base = base[len(self.scheme)+3:]
-            default_port = 443 if self.scheme == 'https' else 80
-            return int(base[base.find(":")+1:]) if ":" in base else default_port
-
-        @property
-        def path_info(self):
-            return self._creq.path_info
-
-
-class RESTController(BaseController):
-    """
-    Base class for providing a RESTful interface to a resource.
-
-    To use this class, simply derive a class from it and implement the methods
-    you want to support.  The list of possible methods are:
-
-    * list()
-    * bulk_set(data)
-    * create(data)
-    * bulk_delete()
-    * get(key)
-    * set(data, key)
-    * singleton_set(data)
-    * delete(key)
-
-    Test with curl:
-
-    curl -H "Content-Type: application/json" -X POST \
-         -d '{"username":"xyz","password":"xyz"}'  https://127.0.0.1:8443/foo
-    curl https://127.0.0.1:8443/foo
-    curl https://127.0.0.1:8443/foo/0
-
-    """
-
-    # resource id parameter for using in get, set, and delete methods
-    # should be overridden by subclasses.
-    # to specify a composite id (two parameters) use '/'. e.g., "param1/param2".
-    # If subclasses don't override this property we try to infer the structure
-    # of the resource ID.
-    RESOURCE_ID = None  # type: Optional[str]
-
-    _permission_map = {
-        'GET': Permission.READ,
-        'POST': Permission.CREATE,
-        'PUT': Permission.UPDATE,
-        'DELETE': Permission.DELETE
-    }
-
-    _method_mapping = collections.OrderedDict([
-        ('list', {'method': 'GET', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
-        ('create', {'method': 'POST', 'resource': False, 'status': 201, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
-        ('bulk_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
-        ('bulk_delete', {'method': 'DELETE', 'resource': False, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
-        ('get', {'method': 'GET', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
-        ('delete', {'method': 'DELETE', 'resource': True, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
-        ('set', {'method': 'PUT', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
-        ('singleton_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT})  # noqa E501 #pylint: disable=line-too-long
-    ])
-
-    @classmethod
-    def infer_resource_id(cls):
-        if cls.RESOURCE_ID is not None:
-            return cls.RESOURCE_ID.split('/')
-        for k, v in cls._method_mapping.items():
-            func = getattr(cls, k, None)
-            while hasattr(func, "__wrapped__"):
-                func = func.__wrapped__
-            if v['resource'] and func:
-                path_params = cls.get_path_param_names()
-                params = _get_function_params(func)
-                return [p['name'] for p in params
-                        if p['required'] and p['name'] not in path_params]
-        return None
-
-    @classmethod
-    def endpoints(cls):
-        result = super(RESTController, cls).endpoints()
-        res_id_params = cls.infer_resource_id()
-
-        for _, func in inspect.getmembers(cls, predicate=callable):
-            endpoint_params = {
-                'no_resource_id_params': False,
-                'status': 200,
-                'method': None,
-                'query_params': None,
-                'path': '',
-                'version': APIVersion.DEFAULT,
-                'sec_permissions': hasattr(func, '_security_permissions'),
-                'permission': None,
-            }
-
-            if func.__name__ in cls._method_mapping:
-                cls._update_endpoint_params_method_map(
-                    func, res_id_params, endpoint_params)
-
-            elif hasattr(func, "__collection_method__"):
-                cls._update_endpoint_params_collection_map(func, endpoint_params)
-
-            elif hasattr(func, "__resource_method__"):
-                cls._update_endpoint_params_resource_method(
-                    res_id_params, endpoint_params, func)
-
-            else:
-                continue
-
-            if endpoint_params['no_resource_id_params']:
-                raise TypeError("Could not infer the resource ID parameters for"
-                                " method {} of controller {}. "
-                                "Please specify the resource ID parameters "
-                                "using the RESOURCE_ID class property"
-                                .format(func.__name__, cls.__name__))
-
-            if endpoint_params['method'] in ['GET', 'DELETE']:
-                params = _get_function_params(func)
-                if res_id_params is None:
-                    res_id_params = []
-                if endpoint_params['query_params'] is None:
-                    endpoint_params['query_params'] = [p['name'] for p in params  # type: ignore
-                                                       if p['name'] not in res_id_params]
-
-            func = cls._status_code_wrapper(func, endpoint_params['status'])
-            endp_func = Endpoint(endpoint_params['method'], path=endpoint_params['path'],
-                                 query_params=endpoint_params['query_params'],
-                                 version=endpoint_params['version'])(func)  # type: ignore
-            if endpoint_params['permission']:
-                _set_func_permissions(endp_func, [endpoint_params['permission']])
-            result.append(cls.Endpoint(cls, endp_func))
-
-        return result
-
-    @classmethod
-    def _update_endpoint_params_resource_method(cls, res_id_params, endpoint_params, func):
-        if not res_id_params:
-            endpoint_params['no_resource_id_params'] = True
-        else:
-            path_params = ["{{{}}}".format(p) for p in res_id_params]
-            endpoint_params['path'] += "/{}".format("/".join(path_params))
-            if func.__resource_method__['path']:
-                endpoint_params['path'] += func.__resource_method__['path']
-            else:
-                endpoint_params['path'] += "/{}".format(func.__name__)
-        endpoint_params['status'] = func.__resource_method__['status']
-        endpoint_params['method'] = func.__resource_method__['method']
-        endpoint_params['version'] = func.__resource_method__['version']
-        endpoint_params['query_params'] = func.__resource_method__['query_params']
-        if not endpoint_params['sec_permissions']:
-            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
-
-    @classmethod
-    def _update_endpoint_params_collection_map(cls, func, endpoint_params):
-        if func.__collection_method__['path']:
-            endpoint_params['path'] = func.__collection_method__['path']
-        else:
-            endpoint_params['path'] = "/{}".format(func.__name__)
-        endpoint_params['status'] = func.__collection_method__['status']
-        endpoint_params['method'] = func.__collection_method__['method']
-        endpoint_params['query_params'] = func.__collection_method__['query_params']
-        endpoint_params['version'] = func.__collection_method__['version']
-        if not endpoint_params['sec_permissions']:
-            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
-
-    @classmethod
-    def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
-        meth = cls._method_mapping[func.__name__]  # type: dict
-
-        if meth['resource']:
-            if not res_id_params:
-                endpoint_params['no_resource_id_params'] = True
-            else:
-                path_params = ["{{{}}}".format(p) for p in res_id_params]
-                endpoint_params['path'] += "/{}".format("/".join(path_params))
-
-        endpoint_params['status'] = meth['status']
-        endpoint_params['method'] = meth['method']
-        if hasattr(func, "__method_map_method__"):
-            endpoint_params['version'] = func.__method_map_method__['version']
-        if not endpoint_params['sec_permissions']:
-            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
-
-    @classmethod
-    def _status_code_wrapper(cls, func, status_code):
-        @wraps(func)
-        def wrapper(*vpath, **params):
-            cherrypy.response.status = status_code
-            return func(*vpath, **params)
-
-        return wrapper
-
-    @staticmethod
-    def Resource(method=None, path=None, status=None, query_params=None,  # noqa: N802
-                 version: Optional[APIVersion] = APIVersion.DEFAULT):
-        if not method:
-            method = 'GET'
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__resource_method__ = {
-                'method': method,
-                'path': path,
-                'status': status,
-                'query_params': query_params,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-    @staticmethod
-    def MethodMap(resource=False, status=None,
-                  version: Optional[APIVersion] = APIVersion.DEFAULT):  # noqa: N802
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__method_map_method__ = {
-                'resource': resource,
-                'status': status,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-    @staticmethod
-    def Collection(method=None, path=None, status=None, query_params=None,  # noqa: N802
-                   version: Optional[APIVersion] = APIVersion.DEFAULT):
-        if not method:
-            method = 'GET'
-
-        if status is None:
-            status = 200
-
-        def _wrapper(func):
-            func.__collection_method__ = {
-                'method': method,
-                'path': path,
-                'status': status,
-                'query_params': query_params,
-                'version': version
-            }
-            return func
-        return _wrapper
-
-
-class ControllerAuthMixin(object):
-    @staticmethod
-    def _delete_token_cookie(token):
-        cherrypy.response.cookie['token'] = token
-        cherrypy.response.cookie['token']['expires'] = 0
-        cherrypy.response.cookie['token']['max-age'] = 0
-
-    @staticmethod
-    def _set_token_cookie(url_prefix, token):
-        cherrypy.response.cookie['token'] = token
-        if url_prefix == 'https':
-            cherrypy.response.cookie['token']['secure'] = True
-        cherrypy.response.cookie['token']['HttpOnly'] = True
-        cherrypy.response.cookie['token']['path'] = '/'
-        cherrypy.response.cookie['token']['SameSite'] = 'Strict'
-
-
-# Role-based access permissions decorators
-
-def _set_func_permissions(func, permissions):
-    if not isinstance(permissions, list):
-        permissions = [permissions]
-
-    for perm in permissions:
-        if not Permission.valid_permission(perm):
-            logger = logging.getLogger('controller.set_func_perms')
-            logger.debug("Invalid security permission: %s\n "
-                         "Possible values: %s", perm,
-                         Permission.all_permissions())
-            raise PermissionNotValid(perm)
-
-    if not hasattr(func, '_security_permissions'):
-        func._security_permissions = permissions
-    else:
-        permissions.extend(func._security_permissions)
-        func._security_permissions = list(set(permissions))
-
-
-def ReadPermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.READ)
-    return func
-
-
-def CreatePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.CREATE)
-    return func
-
-
-def DeletePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.DELETE)
-    return func
-
-
-def UpdatePermission(func):  # noqa: N802
-    """
-    :raises PermissionNotValid: If the permission is missing.
-    """
-    _set_func_permissions(func, Permission.UPDATE)
-    return func
-
-
-# Empty request body decorator
-
-def allow_empty_body(func):  # noqa: N802
-    """
-    The POST/PUT request methods decorated with ``@allow_empty_body``
-    are allowed to send empty request body.
-    """
-    try:
-        func._cp_config['tools.json_in.force'] = False
-    except (AttributeError, KeyError):
-        func._cp_config = {'tools.json_in.force': False}
-    return func
-
-
-def validate_ceph_type(validations, component=''):
-    def decorator(func):
-        @wraps(func)
-        def validate_args(*args, **kwargs):
-            input_values = kwargs
-            for key, ceph_type in validations:
-                try:
-                    ceph_type.valid(input_values[key])
-                except ArgumentFormat as e:
-                    raise DashboardException(msg=e,
-                                             code='ceph_type_not_valid',
-                                             component=component)
-            return func(*args, **kwargs)
-        return validate_args
-    return decorator
+from ._api_router import APIRouter
+from ._auth import ControllerAuthMixin
+from ._base_controller import BaseController
+from ._docs import APIDoc, EndpointDoc
+from ._endpoint import Endpoint, Proxy
+from ._helpers import ENDPOINT_MAP, allow_empty_body, \
+    generate_controller_routes, json_error_page, validate_ceph_type
+from ._permissions import CreatePermission, DeletePermission, ReadPermission, UpdatePermission
+from ._rest_controller import RESTController
+from ._router import Router
+from ._task import Task
+from ._ui_router import UIRouter
+
+__all__ = [
+    'BaseController',
+    'RESTController',
+    'Router',
+    'UIRouter',
+    'APIRouter',
+    'Endpoint',
+    'Proxy',
+    'Task',
+    'ControllerAuthMixin',
+    'EndpointDoc',
+    'APIDoc',
+    'allow_empty_body',
+    'ENDPOINT_MAP',
+    'generate_controller_routes',
+    'json_error_page',
+    'validate_ceph_type',
+    'CreatePermission',
+    'ReadPermission',
+    'UpdatePermission',
+    'DeletePermission'
+]
diff --git a/src/pybind/mgr/dashboard/controllers/_api_router.py b/src/pybind/mgr/dashboard/controllers/_api_router.py
new file mode 100644 (file)
index 0000000..dbd45ac
--- /dev/null
@@ -0,0 +1,13 @@
+from ._router import Router
+
+
+class APIRouter(Router):
+    def __init__(self, path, security_scope=None, secure=True):
+        super().__init__(path, base_url="/api",
+                         security_scope=security_scope,
+                         secure=secure)
+
+    def __call__(self, cls):
+        cls = super().__call__(cls)
+        cls._api_endpoint = True
+        return cls
diff --git a/src/pybind/mgr/dashboard/controllers/_auth.py b/src/pybind/mgr/dashboard/controllers/_auth.py
new file mode 100644 (file)
index 0000000..0015a75
--- /dev/null
@@ -0,0 +1,18 @@
+import cherrypy
+
+
+class ControllerAuthMixin:
+    @staticmethod
+    def _delete_token_cookie(token):
+        cherrypy.response.cookie['token'] = token
+        cherrypy.response.cookie['token']['expires'] = 0
+        cherrypy.response.cookie['token']['max-age'] = 0
+
+    @staticmethod
+    def _set_token_cookie(url_prefix, token):
+        cherrypy.response.cookie['token'] = token
+        if url_prefix == 'https':
+            cherrypy.response.cookie['token']['secure'] = True
+        cherrypy.response.cookie['token']['HttpOnly'] = True
+        cherrypy.response.cookie['token']['path'] = '/'
+        cherrypy.response.cookie['token']['SameSite'] = 'Strict'
diff --git a/src/pybind/mgr/dashboard/controllers/_base_controller.py b/src/pybind/mgr/dashboard/controllers/_base_controller.py
new file mode 100644 (file)
index 0000000..4fcc844
--- /dev/null
@@ -0,0 +1,314 @@
+import inspect
+import json
+import logging
+from functools import wraps
+from typing import ClassVar, List, Optional, Type
+from urllib.parse import unquote
+
+import cherrypy
+
+from ..plugins import PLUGIN_MANAGER
+from ..services.auth import AuthManager, JwtManager
+from ..tools import get_request_body_params
+from ._helpers import _get_function_params
+from ._version import APIVersion
+
+logger = logging.getLogger(__name__)
+
+
+class BaseController:
+    """
+    Base class for all controllers providing API endpoints.
+    """
+
+    _registry: ClassVar[List[Type['BaseController']]] = []
+    _routed = False
+
+    def __init_subclass__(cls, skip_registry: bool = False, **kwargs) -> None:
+        super().__init_subclass__(**kwargs)  # type: ignore
+        if not skip_registry:
+            BaseController._registry.append(cls)
+
+    @classmethod
+    def load_controllers(cls):
+        import importlib
+        from pathlib import Path
+
+        path = Path(__file__).parent
+        logger.debug('Controller import path: %s', path)
+        modules = [
+            f.stem for f in path.glob('*.py') if
+            not f.name.startswith('_') and f.is_file() and not f.is_symlink()]
+        logger.debug('Controller files found: %r', modules)
+
+        for module in modules:
+            importlib.import_module(f'{__package__}.{module}')
+
+        # pylint: disable=protected-access
+        controllers = [
+            controller for controller in BaseController._registry if
+            controller._routed
+        ]
+
+        for clist in PLUGIN_MANAGER.hook.get_controllers() or []:
+            controllers.extend(clist)
+
+        return controllers
+
+    class Endpoint:
+        """
+        An instance of this class represents an endpoint.
+        """
+
+        def __init__(self, ctrl, func):
+            self.ctrl = ctrl
+            self.inst = None
+            self.func = func
+
+            if not self.config['proxy']:
+                setattr(self.ctrl, func.__name__, self.function)
+
+        @property
+        def config(self):
+            func = self.func
+            while not hasattr(func, '_endpoint'):
+                if hasattr(func, "__wrapped__"):
+                    func = func.__wrapped__
+                else:
+                    return None
+            return func._endpoint  # pylint: disable=protected-access
+
+        @property
+        def function(self):
+            # pylint: disable=protected-access
+            return self.ctrl._request_wrapper(self.func, self.method,
+                                              self.config['json_response'],
+                                              self.config['xml'],
+                                              self.config['version'])
+
+        @property
+        def method(self):
+            return self.config['method']
+
+        @property
+        def proxy(self):
+            return self.config['proxy']
+
+        @property
+        def url(self):
+            ctrl_path = self.ctrl.get_path()
+            if ctrl_path == "/":
+                ctrl_path = ""
+            if self.config['path'] is not None:
+                url = "{}{}".format(ctrl_path, self.config['path'])
+            else:
+                url = "{}/{}".format(ctrl_path, self.func.__name__)
+
+            ctrl_path_params = self.ctrl.get_path_param_names(
+                self.config['path'])
+            path_params = [p['name'] for p in self.path_params
+                           if p['name'] not in ctrl_path_params]
+            path_params = ["{{{}}}".format(p) for p in path_params]
+            if path_params:
+                url += "/{}".format("/".join(path_params))
+
+            return url
+
+        @property
+        def action(self):
+            return self.func.__name__
+
+        @property
+        def path_params(self):
+            ctrl_path_params = self.ctrl.get_path_param_names(
+                self.config['path'])
+            func_params = _get_function_params(self.func)
+
+            if self.method in ['GET', 'DELETE']:
+                assert self.config['path_params'] is None
+
+                return [p for p in func_params if p['name'] in ctrl_path_params
+                        or (p['name'] not in self.config['query_params']
+                            and p['required'])]
+
+            # elif self.method in ['POST', 'PUT']:
+            return [p for p in func_params if p['name'] in ctrl_path_params
+                    or p['name'] in self.config['path_params']]
+
+        @property
+        def query_params(self):
+            if self.method in ['GET', 'DELETE']:
+                func_params = _get_function_params(self.func)
+                path_params = [p['name'] for p in self.path_params]
+                return [p for p in func_params if p['name'] not in path_params]
+
+            # elif self.method in ['POST', 'PUT']:
+            func_params = _get_function_params(self.func)
+            return [p for p in func_params
+                    if p['name'] in self.config['query_params']]
+
+        @property
+        def body_params(self):
+            func_params = _get_function_params(self.func)
+            path_params = [p['name'] for p in self.path_params]
+            query_params = [p['name'] for p in self.query_params]
+            return [p for p in func_params
+                    if p['name'] not in path_params
+                    and p['name'] not in query_params]
+
+        @property
+        def group(self):
+            return self.ctrl.__name__
+
+        @property
+        def is_api(self):
+            # changed from hasattr to getattr: some ui-based api inherit _api_endpoint
+            return getattr(self.ctrl, '_api_endpoint', False)
+
+        @property
+        def is_secure(self):
+            return self.ctrl._cp_config['tools.authenticate.on']  # pylint: disable=protected-access
+
+        def __repr__(self):
+            return "Endpoint({}, {}, {})".format(self.url, self.method,
+                                                 self.action)
+
+    def __init__(self):
+        logger.info('Initializing controller: %s -> %s',
+                    self.__class__.__name__, self._cp_path_)  # type: ignore
+        super().__init__()
+
+    def _has_permissions(self, permissions, scope=None):
+        if not self._cp_config['tools.authenticate.on']:  # type: ignore
+            raise Exception("Cannot verify permission in non secured "
+                            "controllers")
+
+        if not isinstance(permissions, list):
+            permissions = [permissions]
+
+        if scope is None:
+            scope = getattr(self, '_security_scope', None)
+        if scope is None:
+            raise Exception("Cannot verify permissions without scope security"
+                            " defined")
+        username = JwtManager.LOCAL_USER.username
+        return AuthManager.authorize(username, scope, permissions)
+
+    @classmethod
+    def get_path_param_names(cls, path_extension=None):
+        if path_extension is None:
+            path_extension = ""
+        full_path = cls._cp_path_[1:] + path_extension  # type: ignore
+        path_params = []
+        for step in full_path.split('/'):
+            param = None
+            if not step:
+                continue
+            if step[0] == ':':
+                param = step[1:]
+            elif step[0] == '{' and step[-1] == '}':
+                param, _, _ = step[1:-1].partition(':')
+            if param:
+                path_params.append(param)
+        return path_params
+
+    @classmethod
+    def get_path(cls):
+        return cls._cp_path_  # type: ignore
+
+    @classmethod
+    def endpoints(cls):
+        """
+        This method iterates over all the methods decorated with ``@endpoint``
+        and creates an Endpoint object for each one of the methods.
+
+        :return: A list of endpoint objects
+        :rtype: list[BaseController.Endpoint]
+        """
+        result = []
+        for _, func in inspect.getmembers(cls, predicate=callable):
+            if hasattr(func, '_endpoint'):
+                result.append(cls.Endpoint(cls, func))
+        return result
+
+    @staticmethod
+    def _request_wrapper(func, method, json_response, xml,  # pylint: disable=unused-argument
+                         version: Optional[APIVersion]):
+        # pylint: disable=too-many-branches
+        @wraps(func)
+        def inner(*args, **kwargs):
+            client_version = None
+            for key, value in kwargs.items():
+                if isinstance(value, str):
+                    kwargs[key] = unquote(value)
+
+            # Process method arguments.
+            params = get_request_body_params(cherrypy.request)
+            kwargs.update(params)
+
+            if version is not None:
+                try:
+                    client_version = APIVersion.from_mime_type(
+                        cherrypy.request.headers['Accept'])
+                except Exception:
+                    raise cherrypy.HTTPError(
+                        415, "Unable to find version in request header")
+
+                if version.supports(client_version):
+                    ret = func(*args, **kwargs)
+                else:
+                    raise cherrypy.HTTPError(
+                        415,
+                        f"Incorrect version: endpoint is '{version!s}', "
+                        f"client requested '{client_version!s}'"
+                    )
+
+            else:
+                ret = func(*args, **kwargs)
+            if isinstance(ret, bytes):
+                ret = ret.decode('utf-8')
+            if xml:
+                if version:
+                    cherrypy.response.headers['Content-Type'] = \
+                        'application/vnd.ceph.api.v{}+xml'.format(version)
+                else:
+                    cherrypy.response.headers['Content-Type'] = 'application/xml'
+                return ret.encode('utf8')
+            if json_response:
+                if version:
+                    cherrypy.response.headers['Content-Type'] = \
+                        'application/vnd.ceph.api.v{}+json'.format(version)
+                else:
+                    cherrypy.response.headers['Content-Type'] = 'application/json'
+                ret = json.dumps(ret).encode('utf8')
+            return ret
+        return inner
+
+    @property
+    def _request(self):
+        return self.Request(cherrypy.request)
+
+    class Request(object):
+        def __init__(self, cherrypy_req):
+            self._creq = cherrypy_req
+
+        @property
+        def scheme(self):
+            return self._creq.scheme
+
+        @property
+        def host(self):
+            base = self._creq.base
+            base = base[len(self.scheme)+3:]
+            return base[:base.find(":")] if ":" in base else base
+
+        @property
+        def port(self):
+            base = self._creq.base
+            base = base[len(self.scheme)+3:]
+            default_port = 443 if self.scheme == 'https' else 80
+            return int(base[base.find(":")+1:]) if ":" in base else default_port
+
+        @property
+        def path_info(self):
+            return self._creq.path_info
diff --git a/src/pybind/mgr/dashboard/controllers/_docs.py b/src/pybind/mgr/dashboard/controllers/_docs.py
new file mode 100644 (file)
index 0000000..5bd7a5a
--- /dev/null
@@ -0,0 +1,128 @@
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from ..api.doc import SchemaInput, SchemaType
+
+
+class EndpointDoc:  # noqa: N802
+    DICT_TYPE = Union[Dict[str, Any], Dict[int, Any]]
+
+    def __init__(self, description: str = "", group: str = "",
+                 parameters: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]] = None,
+                 responses: Optional[DICT_TYPE] = None) -> None:
+        self.description = description
+        self.group = group
+        self.parameters = parameters
+        self.responses = responses
+
+        self.validate_args()
+
+        if not self.parameters:
+            self.parameters = {}  # type: ignore
+
+        self.resp = {}
+        if self.responses:
+            for status_code, response_body in self.responses.items():
+                schema_input = SchemaInput()
+                schema_input.type = SchemaType.ARRAY if \
+                    isinstance(response_body, list) else SchemaType.OBJECT
+                schema_input.params = self._split_parameters(response_body)
+
+                self.resp[str(status_code)] = schema_input
+
+    def validate_args(self) -> None:
+        if not isinstance(self.description, str):
+            raise Exception("%s has been called with a description that is not a string: %s"
+                            % (EndpointDoc.__name__, self.description))
+        if not isinstance(self.group, str):
+            raise Exception("%s has been called with a groupname that is not a string: %s"
+                            % (EndpointDoc.__name__, self.group))
+        if self.parameters and not isinstance(self.parameters, dict):
+            raise Exception("%s has been called with parameters that is not a dict: %s"
+                            % (EndpointDoc.__name__, self.parameters))
+        if self.responses and not isinstance(self.responses, dict):
+            raise Exception("%s has been called with responses that is not a dict: %s"
+                            % (EndpointDoc.__name__, self.responses))
+
+    def _split_param(self, name: str, p_type: Union[type, DICT_TYPE, List[Any], Tuple[Any, ...]],
+                     description: str, optional: bool = False, default_value: Any = None,
+                     nested: bool = False) -> Dict[str, Any]:
+        param = {
+            'name': name,
+            'description': description,
+            'required': not optional,
+            'nested': nested,
+        }
+        if default_value:
+            param['default'] = default_value
+        if isinstance(p_type, type):
+            param['type'] = p_type
+        else:
+            nested_params = self._split_parameters(p_type, nested=True)
+            if nested_params:
+                param['type'] = type(p_type)
+                param['nested_params'] = nested_params
+            else:
+                param['type'] = p_type
+        return param
+
+    #  Optional must be set to True in order to set default value and parameters format must be:
+    # 'name: (type or nested parameters, description, [optional], [default value])'
+    def _split_dict(self, data: DICT_TYPE, nested: bool) -> List[Any]:
+        splitted = []
+        for name, props in data.items():
+            if isinstance(name, str) and isinstance(props, tuple):
+                if len(props) == 2:
+                    param = self._split_param(name, props[0], props[1], nested=nested)
+                elif len(props) == 3:
+                    param = self._split_param(
+                        name, props[0], props[1], optional=props[2], nested=nested)
+                if len(props) == 4:
+                    param = self._split_param(name, props[0], props[1], props[2], props[3], nested)
+                splitted.append(param)
+            else:
+                raise Exception(
+                    """Parameter %s in %s has not correct format. Valid formats are:
+                    <name>: (<type>, <description>, [optional], [default value])
+                    <name>: (<[type]>, <description>, [optional], [default value])
+                    <name>: (<[nested parameters]>, <description>, [optional], [default value])
+                    <name>: (<{nested parameters}>, <description>, [optional], [default value])"""
+                    % (name, EndpointDoc.__name__))
+        return splitted
+
+    def _split_list(self, data: Union[List[Any], Tuple[Any, ...]], nested: bool) -> List[Any]:
+        splitted = []  # type: List[Any]
+        for item in data:
+            splitted.extend(self._split_parameters(item, nested))
+        return splitted
+
+    # nested = True means parameters are inside a dict or array
+    def _split_parameters(self, data: Optional[Union[DICT_TYPE, List[Any], Tuple[Any, ...]]],
+                          nested: bool = False) -> List[Any]:
+        param_list = []  # type: List[Any]
+        if isinstance(data, dict):
+            param_list.extend(self._split_dict(data, nested))
+        elif isinstance(data, (list, tuple)):
+            param_list.extend(self._split_list(data, True))
+        return param_list
+
+    def __call__(self, func: Any) -> Any:
+        func.doc_info = {
+            'summary': self.description,
+            'tag': self.group,
+            'parameters': self._split_parameters(self.parameters),
+            'response': self.resp
+        }
+        return func
+
+
+class APIDoc(object):
+    def __init__(self, description="", group=""):
+        self.tag = group
+        self.tag_descr = description
+
+    def __call__(self, cls):
+        cls.doc_info = {
+            'tag': self.tag,
+            'tag_descr': self.tag_descr
+        }
+        return cls
diff --git a/src/pybind/mgr/dashboard/controllers/_endpoint.py b/src/pybind/mgr/dashboard/controllers/_endpoint.py
new file mode 100644 (file)
index 0000000..fccab89
--- /dev/null
@@ -0,0 +1,82 @@
+from typing import Optional
+
+from ._helpers import _get_function_params
+from ._version import APIVersion
+
+
+class Endpoint:
+
+    def __init__(self, method=None, path=None, path_params=None, query_params=None,  # noqa: N802
+                 json_response=True, proxy=False, xml=False,
+                 version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if method is None:
+            method = 'GET'
+        elif not isinstance(method, str) or \
+                method.upper() not in ['GET', 'POST', 'DELETE', 'PUT']:
+            raise TypeError("Possible values for method are: 'GET', 'POST', "
+                            "'DELETE', or 'PUT'")
+
+        method = method.upper()
+
+        if method in ['GET', 'DELETE']:
+            if path_params is not None:
+                raise TypeError("path_params should not be used for {} "
+                                "endpoints. All function params are considered"
+                                " path parameters by default".format(method))
+
+        if path_params is None:
+            if method in ['POST', 'PUT']:
+                path_params = []
+
+        if query_params is None:
+            query_params = []
+
+        self.method = method
+        self.path = path
+        self.path_params = path_params
+        self.query_params = query_params
+        self.json_response = json_response
+        self.proxy = proxy
+        self.xml = xml
+        self.version = version
+
+    def __call__(self, func):
+        if self.method in ['POST', 'PUT']:
+            func_params = _get_function_params(func)
+            for param in func_params:
+                if param['name'] in self.path_params and not param['required']:
+                    raise TypeError("path_params can only reference "
+                                    "non-optional function parameters")
+
+        if func.__name__ == '__call__' and self.path is None:
+            e_path = ""
+        else:
+            e_path = self.path
+
+        if e_path is not None:
+            e_path = e_path.strip()
+            if e_path and e_path[0] != "/":
+                e_path = "/" + e_path
+            elif e_path == "/":
+                e_path = ""
+
+        func._endpoint = {
+            'method': self.method,
+            'path': e_path,
+            'path_params': self.path_params,
+            'query_params': self.query_params,
+            'json_response': self.json_response,
+            'proxy': self.proxy,
+            'xml': self.xml,
+            'version': self.version
+        }
+        return func
+
+
+def Proxy(path=None):  # noqa: N802
+    if path is None:
+        path = ""
+    elif path == "/":
+        path = ""
+    path += "/{path:.*}"
+    return Endpoint(path=path, proxy=True)
diff --git a/src/pybind/mgr/dashboard/controllers/_helpers.py b/src/pybind/mgr/dashboard/controllers/_helpers.py
new file mode 100644 (file)
index 0000000..5ec49ee
--- /dev/null
@@ -0,0 +1,127 @@
+import collections
+import json
+import logging
+import re
+from functools import wraps
+
+import cherrypy
+from ceph_argparse import ArgumentFormat  # pylint: disable=import-error
+
+from ..exceptions import DashboardException
+from ..tools import getargspec
+
+logger = logging.getLogger(__name__)
+
+
+ENDPOINT_MAP = collections.defaultdict(list)  # type: dict
+
+
+def _get_function_params(func):
+    """
+    Retrieves the list of parameters declared in function.
+    Each parameter is represented as dict with keys:
+      * name (str): the name of the parameter
+      * required (bool): whether the parameter is required or not
+      * default (obj): the parameter's default value
+    """
+    fspec = getargspec(func)
+
+    func_params = []
+    nd = len(fspec.args) if not fspec.defaults else -len(fspec.defaults)
+    for param in fspec.args[1:nd]:
+        func_params.append({'name': param, 'required': True})
+
+    if fspec.defaults:
+        for param, val in zip(fspec.args[nd:], fspec.defaults):
+            func_params.append({
+                'name': param,
+                'required': False,
+                'default': val
+            })
+
+    return func_params
+
+
+def generate_controller_routes(endpoint, mapper, base_url):
+    inst = endpoint.inst
+    ctrl_class = endpoint.ctrl
+
+    if endpoint.proxy:
+        conditions = None
+    else:
+        conditions = dict(method=[endpoint.method])
+
+    # base_url can be empty or a URL path that starts with "/"
+    # we will remove the trailing "/" if exists to help with the
+    # concatenation with the endpoint url below
+    if base_url.endswith("/"):
+        base_url = base_url[:-1]
+
+    endp_url = endpoint.url
+
+    if endp_url.find("/", 1) == -1:
+        parent_url = "{}{}".format(base_url, endp_url)
+    else:
+        parent_url = "{}{}".format(base_url, endp_url[:endp_url.find("/", 1)])
+
+    # parent_url might be of the form "/.../{...}" where "{...}" is a path parameter
+    # we need to remove the path parameter definition
+    parent_url = re.sub(r'(?:/\{[^}]+\})$', '', parent_url)
+    if not parent_url:  # root path case
+        parent_url = "/"
+
+    url = "{}{}".format(base_url, endp_url)
+
+    logger.debug("Mapped [%s] to %s:%s restricted to %s",
+                 url, ctrl_class.__name__, endpoint.action,
+                 endpoint.method)
+
+    ENDPOINT_MAP[endpoint.url].append(endpoint)
+
+    name = ctrl_class.__name__ + ":" + endpoint.action
+    mapper.connect(name, url, controller=inst, action=endpoint.action,
+                   conditions=conditions)
+
+    # adding route with trailing slash
+    name += "/"
+    url += "/"
+    mapper.connect(name, url, controller=inst, action=endpoint.action,
+                   conditions=conditions)
+
+    return parent_url
+
+
+def json_error_page(status, message, traceback, version):
+    cherrypy.response.headers['Content-Type'] = 'application/json'
+    return json.dumps(dict(status=status, detail=message, traceback=traceback,
+                           version=version))
+
+
+def allow_empty_body(func):  # noqa: N802
+    """
+    The POST/PUT request methods decorated with ``@allow_empty_body``
+    are allowed to send empty request body.
+    """
+    # pylint: disable=protected-access
+    try:
+        func._cp_config['tools.json_in.force'] = False
+    except (AttributeError, KeyError):
+        func._cp_config = {'tools.json_in.force': False}
+    return func
+
+
+def validate_ceph_type(validations, component=''):
+    def decorator(func):
+        @wraps(func)
+        def validate_args(*args, **kwargs):
+            input_values = kwargs
+            for key, ceph_type in validations:
+                try:
+                    ceph_type.valid(input_values[key])
+                except ArgumentFormat as e:
+                    raise DashboardException(msg=e,
+                                             code='ceph_type_not_valid',
+                                             component=component)
+            return func(*args, **kwargs)
+        return validate_args
+    return decorator
diff --git a/src/pybind/mgr/dashboard/controllers/_permissions.py b/src/pybind/mgr/dashboard/controllers/_permissions.py
new file mode 100644 (file)
index 0000000..eb190c9
--- /dev/null
@@ -0,0 +1,60 @@
+"""
+Role-based access permissions decorators
+"""
+import logging
+
+from ..exceptions import PermissionNotValid
+from ..security import Permission
+
+logger = logging.getLogger(__name__)
+
+
+def _set_func_permissions(func, permissions):
+    if not isinstance(permissions, list):
+        permissions = [permissions]
+
+    for perm in permissions:
+        if not Permission.valid_permission(perm):
+            logger.debug("Invalid security permission: %s\n "
+                         "Possible values: %s", perm,
+                         Permission.all_permissions())
+            raise PermissionNotValid(perm)
+
+    # pylint: disable=protected-access
+    if not hasattr(func, '_security_permissions'):
+        func._security_permissions = permissions
+    else:
+        permissions.extend(func._security_permissions)
+        func._security_permissions = list(set(permissions))
+
+
+def ReadPermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.READ)
+    return func
+
+
+def CreatePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.CREATE)
+    return func
+
+
+def DeletePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.DELETE)
+    return func
+
+
+def UpdatePermission(func):  # noqa: N802
+    """
+    :raises PermissionNotValid: If the permission is missing.
+    """
+    _set_func_permissions(func, Permission.UPDATE)
+    return func
diff --git a/src/pybind/mgr/dashboard/controllers/_rest_controller.py b/src/pybind/mgr/dashboard/controllers/_rest_controller.py
new file mode 100644 (file)
index 0000000..03e124f
--- /dev/null
@@ -0,0 +1,249 @@
+import collections
+import inspect
+from functools import wraps
+from typing import Optional
+
+import cherrypy
+
+from ..security import Permission
+from ._base_controller import BaseController
+from ._endpoint import Endpoint
+from ._helpers import _get_function_params
+from ._permissions import _set_func_permissions
+from ._version import APIVersion
+
+
+class RESTController(BaseController, skip_registry=True):
+    """
+    Base class for providing a RESTful interface to a resource.
+
+    To use this class, simply derive a class from it and implement the methods
+    you want to support.  The list of possible methods are:
+
+    * list()
+    * bulk_set(data)
+    * create(data)
+    * bulk_delete()
+    * get(key)
+    * set(data, key)
+    * singleton_set(data)
+    * delete(key)
+
+    Test with curl:
+
+    curl -H "Content-Type: application/json" -X POST \
+         -d '{"username":"xyz","password":"xyz"}'  https://127.0.0.1:8443/foo
+    curl https://127.0.0.1:8443/foo
+    curl https://127.0.0.1:8443/foo/0
+
+    """
+
+    # resource id parameter for using in get, set, and delete methods
+    # should be overridden by subclasses.
+    # to specify a composite id (two parameters) use '/'. e.g., "param1/param2".
+    # If subclasses don't override this property we try to infer the structure
+    # of the resource ID.
+    RESOURCE_ID: Optional[str] = None
+
+    _permission_map = {
+        'GET': Permission.READ,
+        'POST': Permission.CREATE,
+        'PUT': Permission.UPDATE,
+        'DELETE': Permission.DELETE
+    }
+
+    _method_mapping = collections.OrderedDict([
+        ('list', {'method': 'GET', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('create', {'method': 'POST', 'resource': False, 'status': 201, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('bulk_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('bulk_delete', {'method': 'DELETE', 'resource': False, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('get', {'method': 'GET', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
+        ('delete', {'method': 'DELETE', 'resource': True, 'status': 204, 'version': APIVersion.DEFAULT}),  # noqa E501 #pylint: disable=line-too-long
+        ('set', {'method': 'PUT', 'resource': True, 'status': 200, 'version': APIVersion.DEFAULT}),
+        ('singleton_set', {'method': 'PUT', 'resource': False, 'status': 200, 'version': APIVersion.DEFAULT})  # noqa E501 #pylint: disable=line-too-long
+    ])
+
+    @classmethod
+    def infer_resource_id(cls):
+        if cls.RESOURCE_ID is not None:
+            return cls.RESOURCE_ID.split('/')
+        for k, v in cls._method_mapping.items():
+            func = getattr(cls, k, None)
+            while hasattr(func, "__wrapped__"):
+                func = func.__wrapped__
+            if v['resource'] and func:
+                path_params = cls.get_path_param_names()
+                params = _get_function_params(func)
+                return [p['name'] for p in params
+                        if p['required'] and p['name'] not in path_params]
+        return None
+
+    @classmethod
+    def endpoints(cls):
+        result = super().endpoints()
+        res_id_params = cls.infer_resource_id()
+
+        for _, func in inspect.getmembers(cls, predicate=callable):
+            endpoint_params = {
+                'no_resource_id_params': False,
+                'status': 200,
+                'method': None,
+                'query_params': None,
+                'path': '',
+                'version': APIVersion.DEFAULT,
+                'sec_permissions': hasattr(func, '_security_permissions'),
+                'permission': None,
+            }
+
+            if func.__name__ in cls._method_mapping:
+                cls._update_endpoint_params_method_map(
+                    func, res_id_params, endpoint_params)
+
+            elif hasattr(func, "__collection_method__"):
+                cls._update_endpoint_params_collection_map(func, endpoint_params)
+
+            elif hasattr(func, "__resource_method__"):
+                cls._update_endpoint_params_resource_method(
+                    res_id_params, endpoint_params, func)
+
+            else:
+                continue
+
+            if endpoint_params['no_resource_id_params']:
+                raise TypeError("Could not infer the resource ID parameters for"
+                                " method {} of controller {}. "
+                                "Please specify the resource ID parameters "
+                                "using the RESOURCE_ID class property"
+                                .format(func.__name__, cls.__name__))
+
+            if endpoint_params['method'] in ['GET', 'DELETE']:
+                params = _get_function_params(func)
+                if res_id_params is None:
+                    res_id_params = []
+                if endpoint_params['query_params'] is None:
+                    endpoint_params['query_params'] = [p['name'] for p in params  # type: ignore
+                                                       if p['name'] not in res_id_params]
+
+            func = cls._status_code_wrapper(func, endpoint_params['status'])
+            endp_func = Endpoint(endpoint_params['method'], path=endpoint_params['path'],
+                                 query_params=endpoint_params['query_params'],
+                                 version=endpoint_params['version'])(func)  # type: ignore
+            if endpoint_params['permission']:
+                _set_func_permissions(endp_func, [endpoint_params['permission']])
+            result.append(cls.Endpoint(cls, endp_func))
+
+        return result
+
+    @classmethod
+    def _update_endpoint_params_resource_method(cls, res_id_params, endpoint_params, func):
+        if not res_id_params:
+            endpoint_params['no_resource_id_params'] = True
+        else:
+            path_params = ["{{{}}}".format(p) for p in res_id_params]
+            endpoint_params['path'] += "/{}".format("/".join(path_params))
+            if func.__resource_method__['path']:
+                endpoint_params['path'] += func.__resource_method__['path']
+            else:
+                endpoint_params['path'] += "/{}".format(func.__name__)
+        endpoint_params['status'] = func.__resource_method__['status']
+        endpoint_params['method'] = func.__resource_method__['method']
+        endpoint_params['version'] = func.__resource_method__['version']
+        endpoint_params['query_params'] = func.__resource_method__['query_params']
+        if not endpoint_params['sec_permissions']:
+            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
+
+    @classmethod
+    def _update_endpoint_params_collection_map(cls, func, endpoint_params):
+        if func.__collection_method__['path']:
+            endpoint_params['path'] = func.__collection_method__['path']
+        else:
+            endpoint_params['path'] = "/{}".format(func.__name__)
+        endpoint_params['status'] = func.__collection_method__['status']
+        endpoint_params['method'] = func.__collection_method__['method']
+        endpoint_params['query_params'] = func.__collection_method__['query_params']
+        endpoint_params['version'] = func.__collection_method__['version']
+        if not endpoint_params['sec_permissions']:
+            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
+
+    @classmethod
+    def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
+        meth = cls._method_mapping[func.__name__]  # type: dict
+
+        if meth['resource']:
+            if not res_id_params:
+                endpoint_params['no_resource_id_params'] = True
+            else:
+                path_params = ["{{{}}}".format(p) for p in res_id_params]
+                endpoint_params['path'] += "/{}".format("/".join(path_params))
+
+        endpoint_params['status'] = meth['status']
+        endpoint_params['method'] = meth['method']
+        if hasattr(func, "__method_map_method__"):
+            endpoint_params['version'] = func.__method_map_method__['version']
+        if not endpoint_params['sec_permissions']:
+            endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
+
+    @classmethod
+    def _status_code_wrapper(cls, func, status_code):
+        @wraps(func)
+        def wrapper(*vpath, **params):
+            cherrypy.response.status = status_code
+            return func(*vpath, **params)
+
+        return wrapper
+
+    @staticmethod
+    def Resource(method=None, path=None, status=None, query_params=None,  # noqa: N802
+                 version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if not method:
+            method = 'GET'
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__resource_method__ = {
+                'method': method,
+                'path': path,
+                'status': status,
+                'query_params': query_params,
+                'version': version
+            }
+            return func
+        return _wrapper
+
+    @staticmethod
+    def MethodMap(resource=False, status=None,
+                  version: Optional[APIVersion] = APIVersion.DEFAULT):  # noqa: N802
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__method_map_method__ = {
+                'resource': resource,
+                'status': status,
+                'version': version
+            }
+            return func
+        return _wrapper
+
+    @staticmethod
+    def Collection(method=None, path=None, status=None, query_params=None,  # noqa: N802
+                   version: Optional[APIVersion] = APIVersion.DEFAULT):
+        if not method:
+            method = 'GET'
+
+        if status is None:
+            status = 200
+
+        def _wrapper(func):
+            func.__collection_method__ = {
+                'method': method,
+                'path': path,
+                'status': status,
+                'query_params': query_params,
+                'version': version
+            }
+            return func
+        return _wrapper
diff --git a/src/pybind/mgr/dashboard/controllers/_router.py b/src/pybind/mgr/dashboard/controllers/_router.py
new file mode 100644 (file)
index 0000000..ad67532
--- /dev/null
@@ -0,0 +1,69 @@
+import logging
+
+import cherrypy
+
+from ..exceptions import ScopeNotValid
+from ..security import Scope
+from ._base_controller import BaseController
+from ._helpers import generate_controller_routes
+
+logger = logging.getLogger(__name__)
+
+
+class Router(object):
+    def __init__(self, path, base_url=None, security_scope=None, secure=True):
+        if security_scope and not Scope.valid_scope(security_scope):
+            raise ScopeNotValid(security_scope)
+        self.path = path
+        self.base_url = base_url
+        self.security_scope = security_scope
+        self.secure = secure
+
+        if self.path and self.path[0] != "/":
+            self.path = "/" + self.path
+
+        if self.base_url is None:
+            self.base_url = ""
+        elif self.base_url == "/":
+            self.base_url = ""
+
+        if self.base_url == "" and self.path == "":
+            self.base_url = "/"
+
+    def __call__(self, cls):
+        cls._routed = True
+        cls._cp_path_ = "{}{}".format(self.base_url, self.path)
+        cls._security_scope = self.security_scope
+
+        config = {
+            'tools.dashboard_exception_handler.on': True,
+            'tools.authenticate.on': self.secure,
+        }
+        if not hasattr(cls, '_cp_config'):
+            cls._cp_config = {}
+        cls._cp_config.update(config)
+        return cls
+
+    @classmethod
+    def generate_routes(cls, url_prefix):
+        controllers = BaseController.load_controllers()
+        logger.debug("controllers=%r", controllers)
+
+        mapper = cherrypy.dispatch.RoutesDispatcher()
+
+        parent_urls = set()
+
+        endpoint_list = []
+        for ctrl in controllers:
+            inst = ctrl()
+            for endpoint in ctrl.endpoints():
+                endpoint.inst = inst
+                endpoint_list.append(endpoint)
+
+        endpoint_list = sorted(endpoint_list, key=lambda e: e.url)
+        for endpoint in endpoint_list:
+            parent_urls.add(generate_controller_routes(endpoint, mapper,
+                                                       "{}".format(url_prefix)))
+
+        logger.debug("list of parent paths: %s", parent_urls)
+        return mapper, parent_urls
diff --git a/src/pybind/mgr/dashboard/controllers/_task.py b/src/pybind/mgr/dashboard/controllers/_task.py
new file mode 100644 (file)
index 0000000..33399e8
--- /dev/null
@@ -0,0 +1,79 @@
+from functools import wraps
+
+import cherrypy
+
+from ..tools import TaskManager
+from ._helpers import _get_function_params
+
+
+class Task:
+    def __init__(self, name, metadata, wait_for=5.0, exception_handler=None):
+        self.name = name
+        if isinstance(metadata, list):
+            self.metadata = {e[1:-1]: e for e in metadata}
+        else:
+            self.metadata = metadata
+        self.wait_for = wait_for
+        self.exception_handler = exception_handler
+
+    def _gen_arg_map(self, func, args, kwargs):
+        arg_map = {}
+        params = _get_function_params(func)
+
+        args = args[1:]  # exclude self
+        for idx, param in enumerate(params):
+            if idx < len(args):
+                arg_map[param['name']] = args[idx]
+            else:
+                if param['name'] in kwargs:
+                    arg_map[param['name']] = kwargs[param['name']]
+                else:
+                    assert not param['required'], "{0} is required".format(param['name'])
+                    arg_map[param['name']] = param['default']
+
+            if param['name'] in arg_map:
+                # This is not a type error. We are using the index here.
+                arg_map[idx+1] = arg_map[param['name']]
+
+        return arg_map
+
+    def __call__(self, func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            arg_map = self._gen_arg_map(func, args, kwargs)
+            metadata = {}
+            for k, v in self.metadata.items():
+                if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}':
+                    param = v[1:-1]
+                    try:
+                        pos = int(param)
+                        metadata[k] = arg_map[pos]
+                    except ValueError:
+                        if param.find('.') == -1:
+                            metadata[k] = arg_map[param]
+                        else:
+                            path = param.split('.')
+                            metadata[k] = arg_map[path[0]]
+                            for i in range(1, len(path)):
+                                metadata[k] = metadata[k][path[i]]
+                else:
+                    metadata[k] = v
+            task = TaskManager.run(self.name, metadata, func, args, kwargs,
+                                   exception_handler=self.exception_handler)
+            try:
+                status, value = task.wait(self.wait_for)
+            except Exception as ex:
+                if task.ret_value:
+                    # exception was handled by task.exception_handler
+                    if 'status' in task.ret_value:
+                        status = task.ret_value['status']
+                    else:
+                        status = getattr(ex, 'status', 500)
+                    cherrypy.response.status = status
+                    return task.ret_value
+                raise ex
+            if status == TaskManager.VALUE_EXECUTING:
+                cherrypy.response.status = 202
+                return {'name': self.name, 'metadata': metadata}
+            return value
+        return wrapper
diff --git a/src/pybind/mgr/dashboard/controllers/_ui_router.py b/src/pybind/mgr/dashboard/controllers/_ui_router.py
new file mode 100644 (file)
index 0000000..7454afa
--- /dev/null
@@ -0,0 +1,13 @@
+from ._router import Router
+
+
+class UIRouter(Router):
+    def __init__(self, path, security_scope=None, secure=True):
+        super().__init__(path, base_url="/ui-api",
+                         security_scope=security_scope,
+                         secure=secure)
+
+    def __call__(self, cls):
+        cls = super().__call__(cls)
+        cls._api_endpoint = False
+        return cls
index ed219dc72b0b2d6c20440ed5cf39d5d00a112698..353d5d72bb9e83e7b7e6db7adb3faf004937d150 100644 (file)
@@ -8,8 +8,7 @@ from .. import mgr
 from ..exceptions import InvalidCredentialsError, UserDoesNotExist
 from ..services.auth import AuthManager, JwtManager
 from ..settings import Settings
-from . import ApiController, ControllerAuthMixin, ControllerDoc, EndpointDoc, \
-    RESTController, allow_empty_body
+from . import APIDoc, APIRouter, ControllerAuthMixin, EndpointDoc, RESTController, allow_empty_body
 
 # Python 3.8 introduced `samesite` attribute:
 # https://docs.python.org/3/library/http.cookies.html#morsel-objects
@@ -28,8 +27,8 @@ AUTH_CHECK_SCHEMA = {
 }
 
 
-@ApiController('/auth', secure=False)
-@ControllerDoc("Initiate a session with Ceph", "Auth")
+@APIRouter('/auth', secure=False)
+@APIDoc("Initiate a session with Ceph", "Auth")
 class Auth(RESTController, ControllerAuthMixin):
     """
     Provide authenticates and returns JWT token.
index 0a196913c2fe7415e4d15d18ae082fb096d5a464..d32cbe4bcfab6b307b285c5e865126b5f685f884 100644 (file)
@@ -11,8 +11,7 @@ from ..security import Scope
 from ..services.ceph_service import CephService
 from ..services.cephfs import CephFS as CephFS_
 from ..tools import ViewCache
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, \
-    UiApiController, allow_empty_body
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter, allow_empty_body
 
 GET_QUOTAS_SCHEMA = {
     'max_bytes': (int, ''),
@@ -20,11 +19,11 @@ GET_QUOTAS_SCHEMA = {
 }
 
 
-@ApiController('/cephfs', Scope.CEPHFS)
-@ControllerDoc("Cephfs Management API", "Cephfs")
+@APIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Cephfs Management API", "Cephfs")
 class CephFS(RESTController):
     def __init__(self):  # pragma: no cover
-        super(CephFS, self).__init__()
+        super().__init__()
 
         # Stateful instances of CephFSClients, hold cached results.  Key to
         # dict is FSCID
@@ -490,8 +489,8 @@ class CephFSClients(object):
         return CephService.send_command('mds', 'session ls', srv_spec='{0}:0'.format(self.fscid))
 
 
-@UiApiController('/cephfs', Scope.CEPHFS)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "CephFSUi")
+@UIRouter('/cephfs', Scope.CEPHFS)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CephFSUi")
 class CephFsUi(CephFS):
     RESOURCE_ID = 'fs_id'
 
index 871b629ee4c2e3b2d909acfd531c3608132b1add..da5be2cc81d6a5a8a8ec3fa049efe97a49f6a101 100644 (file)
@@ -6,7 +6,7 @@ from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 FILTER_SCHEMA = [{
     "name": (str, 'Name of the config option'),
@@ -27,8 +27,8 @@ FILTER_SCHEMA = [{
 }]
 
 
-@ApiController('/cluster_conf', Scope.CONFIG_OPT)
-@ControllerDoc("Manage Cluster Configurations", "ClusterConfiguration")
+@APIRouter('/cluster_conf', Scope.CONFIG_OPT)
+@APIDoc("Manage Cluster Configurations", "ClusterConfiguration")
 class ClusterConfiguration(RESTController):
 
     def _append_config_option_values(self, options):
index f57f5d4e3ee1a61189561772d5fb31b632f16fd8..250f657b2bae691622a84a6a2c0025525c813d52 100644 (file)
@@ -1,23 +1,28 @@
+
 # -*- coding: utf-8 -*-
+from __future__ import absolute_import
 
 from cherrypy import NotFound
 
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, APIVersion, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
+from ._version import APIVersion
 
 LIST_SCHEMA = {
     "rule_id": (int, 'Rule ID'),
     "rule_name": (str, 'Rule Name'),
+    "ruleset": (int, 'RuleSet related to the rule'),
     "type": (int, 'Type of Rule'),
+    "min_size": (int, 'Minimum size of Rule'),
+    "max_size": (int, 'Maximum size of Rule'),
     'steps': ([{str}], 'Steps included in the rule')
 }
 
 
-@ApiController('/crush_rule', Scope.POOL)
-@ControllerDoc("Crush Rule Management API", "CrushRule")
+@APIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Crush Rule Management API", "CrushRule")
 class CrushRule(RESTController):
     @EndpointDoc("List Crush Rule Configuration",
                  responses={200: LIST_SCHEMA})
@@ -46,8 +51,8 @@ class CrushRule(RESTController):
         CephService.send_command('mon', 'osd crush rule rm', name=name)
 
 
-@UiApiController('/crush_rule', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "CrushRuleUi")
+@UIRouter('/crush_rule', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "CrushRuleUi")
 class CrushRuleUi(CrushRule):
     @Endpoint()
     @ReadPermission
index 87326aaa0a2581d10ea65936cec72cbcbe25dfd2..ab9f5687b532ba33424d12233d5e19bfde8cf12e 100644 (file)
@@ -6,14 +6,15 @@ import cherrypy
 
 from .. import mgr
 from ..api.doc import Schema, SchemaInput, SchemaType
-from . import ENDPOINT_MAP, APIVersion, BaseController, Controller, Endpoint
+from . import ENDPOINT_MAP, BaseController, Endpoint, Router
+from ._version import APIVersion
 
 NO_DESCRIPTION_AVAILABLE = "*No description available*"
 
 logger = logging.getLogger('controllers.docs')
 
 
-@Controller('/docs', secure=False)
+@Router('/docs', secure=False)
 class Docs(BaseController):
 
     @classmethod
@@ -404,8 +405,6 @@ if __name__ == "__main__":
 
     import yaml
 
-    from . import generate_routes
-
     def fix_null_descr(obj):
         """
         A hot fix for errors caused by null description values when generating
@@ -415,7 +414,7 @@ if __name__ == "__main__":
         return {k: fix_null_descr(v) for k, v in obj.items() if v is not None} \
             if isinstance(obj, dict) else obj
 
-    generate_routes("/api")
+    Router.generate_routes("/api")
     try:
         with open(sys.argv[1], 'w') as f:
             # pylint: disable=protected-access
index a62f60c1a9052c3730b4df25ade2d93353f2140c..d0966025aa65070f37eac65764432646562b8d85 100644 (file)
@@ -5,8 +5,7 @@ from cherrypy import NotFound
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController, UIRouter
 
 LIST_CODE__SCHEMA = {
     "crush-failure-domain": (str, ''),
@@ -18,8 +17,8 @@ LIST_CODE__SCHEMA = {
 }
 
 
-@ApiController('/erasure_code_profile', Scope.POOL)
-@ControllerDoc("Erasure Code Profile Management API", "ErasureCodeProfile")
+@APIRouter('/erasure_code_profile', Scope.POOL)
+@APIDoc("Erasure Code Profile Management API", "ErasureCodeProfile")
 class ErasureCodeProfile(RESTController):
     """
     create() supports additional key-value arguments that are passed to the
@@ -46,8 +45,8 @@ class ErasureCodeProfile(RESTController):
         CephService.send_command('mon', 'osd erasure-code-profile rm', name=name)
 
 
-@UiApiController('/erasure_code_profile', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "ErasureCodeProfileUi")
+@UIRouter('/erasure_code_profile', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "ErasureCodeProfileUi")
 class ErasureCodeProfileUi(ErasureCodeProfile):
     @Endpoint()
     @ReadPermission
index 1ae8c15c6b06b1aaed48e845b5493e59fa4978b3..e1a9eb31d6c20a30948dd3e3ba9bee03eb5495ff 100644 (file)
@@ -5,16 +5,16 @@ from ..model.feedback import Feedback
 from ..rest_client import RequestException
 from ..security import Scope
 from ..services import feedback
-from . import ControllerDoc, RESTController, UiApiController
+from . import APIDoc, APIRouter, RESTController
 
 
-@UiApiController('/feedback', Scope.CONFIG_OPT)
-@ControllerDoc("Feedback API", "Report")
+@APIRouter('/feedback', Scope.CONFIG_OPT)
+@APIDoc("Feedback API", "Report")
 class FeedbackController(RESTController):
     issueAPIkey = None
 
     def __init__(self):  # pragma: no cover
-        super(FeedbackController, self).__init__()
+        super().__init__()
         self.tracker_client = feedback.CephTrackerClient()
 
     def create(self, project, tracker, subject, description):
index 1d3e3cdd6255dde41fee8be95ddb7cf12ddc5535..df9ca19ccfe0826ac5a780b203451b25f2147ee6 100644 (file)
@@ -1,11 +1,11 @@
 import logging
 
-from . import BaseController, Endpoint, UiApiController
+from . import BaseController, Endpoint, UIRouter
 
 logger = logging.getLogger('frontend.error')
 
 
-@UiApiController('/logging', secure=False)
+@UIRouter('/logging', secure=False)
 class FrontendLogging(BaseController):
 
     @Endpoint('POST', path='js-error')
index a92a390adee6f03526feddbe69f0091ad2df14d7..d5c9b19f8087830133c5a5ae493ba134462578f8 100644 (file)
@@ -4,16 +4,16 @@ from ..exceptions import DashboardException
 from ..grafana import GrafanaRestClient, push_local_dashboards
 from ..security import Scope
 from ..settings import Settings
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, UpdatePermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, UpdatePermission
 
 URL_SCHEMA = {
     "instance": (str, "grafana instance")
 }
 
 
-@ApiController('/grafana', Scope.GRAFANA)
-@ControllerDoc("Grafana Management API", "Grafana")
+@APIRouter('/grafana', Scope.GRAFANA)
+@APIDoc("Grafana Management API", "Grafana")
 class Grafana(BaseController):
     @Endpoint()
     @ReadPermission
index 4e402ee65bd60f26c36a67dad7d171bf357cd572..f0964ba19239d3a8bcc8b7aa394ba6d9ab61ed0b 100644 (file)
@@ -9,7 +9,7 @@ from ..services.ceph_service import CephService
 from ..services.iscsi_cli import IscsiGatewaysConfig
 from ..services.iscsi_client import IscsiClient
 from ..tools import partial_dict
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
 from .host import get_hosts
 
 HEALTH_MINIMAL_SCHEMA = ({
@@ -274,11 +274,11 @@ class HealthData(object):
         return CephService.get_scrub_status()
 
 
-@ApiController('/health')
-@ControllerDoc("Display Detailed Cluster health Status", "Health")
+@APIRouter('/health')
+@APIDoc("Display Detailed Cluster health Status", "Health")
 class Health(BaseController):
     def __init__(self):
-        super(Health, self).__init__()
+        super().__init__()
         self.health_full = HealthData(self._has_permissions, minimal=False)
         self.health_minimal = HealthData(self._has_permissions, minimal=True)
 
index 809b50617b10299f5581070e292346908bb7501a..32b0071342acaf01ded926ffb5d09d0b265bd8ab 100644 (file)
@@ -14,7 +14,7 @@ import cherrypy
 from cherrypy.lib.static import serve_file
 
 from .. import mgr
-from . import BaseController, Controller, Endpoint, Proxy, UiApiController
+from . import BaseController, Endpoint, Proxy, Router, UIRouter
 
 logger = logging.getLogger("controllers.home")
 
@@ -51,10 +51,10 @@ class LanguageMixin(object):
         self.DEFAULT_LANGUAGE = config['config']['locale']
         self.DEFAULT_LANGUAGE_PATH = os.path.join(mgr.get_frontend_path(),
                                                   self.DEFAULT_LANGUAGE)
-        super(LanguageMixin, self).__init__()
+        super().__init__()
 
 
-@Controller("/", secure=False)
+@Router("/", secure=False)
 class HomeController(BaseController, LanguageMixin):
     LANG_TAG_SEQ_RE = re.compile(r'\s*([^,]+)\s*,?\s*')
     LANG_TAG_RE = re.compile(
@@ -134,7 +134,7 @@ class HomeController(BaseController, LanguageMixin):
         return serve_file(full_path)
 
 
-@UiApiController("/langs", secure=False)
+@UIRouter("/langs", secure=False)
 class LangsController(BaseController, LanguageMixin):
     @Endpoint('GET')
     def __call__(self):
index dc1aef3aebc5e987b5c6d4d8569518bce64e09c3..a72c4b34052d5e6d5c9c052f0098bf851fe9331d 100644 (file)
@@ -16,9 +16,10 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, APIVersion, BaseController, ControllerDoc, \
-    Endpoint, EndpointDoc, ReadPermission, RESTController, Task, \
-    UiApiController, UpdatePermission, allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter, UpdatePermission, \
+    allow_empty_body
+from ._version import APIVersion
 from .orchestrator import raise_if_no_orchestrator
 
 LIST_HOST_SCHEMA = {
@@ -266,8 +267,8 @@ def add_host(hostname: str, addr: Optional[str] = None,
         orch_client.hosts.enter_maintenance(hostname)
 
 
-@ApiController('/host', Scope.HOSTS)
-@ControllerDoc("Get Host Details", "Host")
+@APIRouter('/host', Scope.HOSTS)
+@APIDoc("Get Host Details", "Host")
 class Host(RESTController):
     @EndpointDoc("List Host Specifications",
                  parameters={
@@ -448,7 +449,7 @@ class Host(RESTController):
                 orch.hosts.add_label(hostname, label)
 
 
-@UiApiController('/host', Scope.HOSTS)
+@UIRouter('/host', Scope.HOSTS)
 class HostUi(BaseController):
     @Endpoint('GET')
     @ReadPermission
index 35de24c9b1424df96e093f7bd5265bc737f94aae..4754c1fab460abb2de0514df0571242c3df085ae 100644 (file)
@@ -23,9 +23,8 @@ from ..services.iscsi_config import IscsiGatewayDoesNotExist
 from ..services.rbd import format_bitmask
 from ..services.tcmu_service import TcmuService
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UiApiController, \
-    UpdatePermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter, UpdatePermission
 
 ISCSI_SCHEMA = {
     'user': (str, 'username'),
@@ -35,7 +34,7 @@ ISCSI_SCHEMA = {
 }
 
 
-@UiApiController('/iscsi', Scope.ISCSI)
+@UIRouter('/iscsi', Scope.ISCSI)
 class IscsiUi(BaseController):
 
     REQUIRED_CEPH_ISCSI_CONFIG_MIN_VERSION = 10
@@ -199,8 +198,8 @@ class IscsiUi(BaseController):
         return result_gateways
 
 
-@ApiController('/iscsi', Scope.ISCSI)
-@ControllerDoc("Iscsi Management API", "Iscsi")
+@APIRouter('/iscsi', Scope.ISCSI)
+@APIDoc("Iscsi Management API", "Iscsi")
 class Iscsi(BaseController):
     @Endpoint('GET', 'discoveryauth')
     @ReadPermission
@@ -256,8 +255,8 @@ def iscsi_target_task(name, metadata, wait_for=2.0):
     return Task("iscsi/target/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/iscsi/target', Scope.ISCSI)
-@ControllerDoc("Get Iscsi Target Details", "IscsiTarget")
+@APIRouter('/iscsi/target', Scope.ISCSI)
+@APIDoc("Get Iscsi Target Details", "IscsiTarget")
 class IscsiTarget(RESTController):
 
     def list(self):
index 0efd37cfefc5c8d7a3d60637de08d2c2201e1b8c..133c33477b585464eb206efe0071ef2420182bfd 100644 (file)
@@ -5,7 +5,7 @@ import collections
 from ..security import Scope
 from ..services.ceph_service import CephService
 from ..tools import NotificationQueue
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
 
 LOG_BUFFER_SIZE = 30
 
@@ -30,11 +30,11 @@ LOGS_SCHEMA = {
 }
 
 
-@ApiController('/logs', Scope.LOG)
-@ControllerDoc("Logs Management API", "Logs")
+@APIRouter('/logs', Scope.LOG)
+@APIDoc("Logs Management API", "Logs")
 class Logs(BaseController):
     def __init__(self):
-        super(Logs, self).__init__()
+        super().__init__()
         self._log_initialized = False
         self.log_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
         self.audit_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
index e6095841ff95241e9ecb361474f5d38c7dd0fe1b..57bb9b5ffb8f077ec2413021a2c4c6302e45e3e2 100644 (file)
@@ -5,7 +5,7 @@ from ..security import Scope
 from ..services.ceph_service import CephService
 from ..services.exception import handle_send_command_error
 from ..tools import find_object_in_list, str_to_bool
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, allow_empty_body
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body
 
 MGR_MODULE_SCHEMA = ([{
     "name": (str, "Module Name"),
@@ -30,8 +30,8 @@ MGR_MODULE_SCHEMA = ([{
 }])
 
 
-@ApiController('/mgr/module', Scope.CONFIG_OPT)
-@ControllerDoc("Get details of MGR Module", "MgrModule")
+@APIRouter('/mgr/module', Scope.CONFIG_OPT)
+@APIDoc("Get details of MGR Module", "MgrModule")
 class MgrModules(RESTController):
     ignore_modules = ['selftest']
 
index 986fddc7f730893a18eeae0ad97f4b866acfac8a..288b6977acea5dbc9a4cd4512ebdb4a850b49d1c 100644 (file)
@@ -4,7 +4,7 @@ import json
 
 from .. import mgr
 from ..security import Scope
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, ReadPermission
 
 MONITOR_SCHEMA = {
     "mon_status": ({
@@ -98,8 +98,8 @@ MONITOR_SCHEMA = {
 }
 
 
-@ApiController('/monitor', Scope.MONITOR)
-@ControllerDoc("Get Monitor Details", "Monitor")
+@APIRouter('/monitor', Scope.MONITOR)
+@APIDoc("Get Monitor Details", "Monitor")
 class Monitor(BaseController):
     @Endpoint()
     @ReadPermission
index b2d9ae5505f00983ec77667b79042e1831099c40..7c740587381b60acad0ccd58f65af39e55eadb83 100644 (file)
@@ -14,8 +14,8 @@ from ..services.exception import DashboardException, serialize_dashboard_excepti
 from ..services.ganesha import Ganesha, GaneshaConf, NFSException
 from ..services.rgw_client import NoCredentialsException, \
     NoRgwDaemonsException, RequestException, RgwClient
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UiApiController
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UIRouter
 
 logger = logging.getLogger('controllers.ganesha')
 
@@ -86,8 +86,8 @@ def NfsTask(name, metadata, wait_for):  # noqa: N802
     return composed_decorator
 
 
-@ApiController('/nfs-ganesha', Scope.NFS_GANESHA)
-@ControllerDoc("NFS-Ganesha Management API", "NFS-Ganesha")
+@APIRouter('/nfs-ganesha', Scope.NFS_GANESHA)
+@APIDoc("NFS-Ganesha Management API", "NFS-Ganesha")
 class NFSGanesha(RESTController):
 
     @EndpointDoc("Status of NFS-Ganesha management feature",
@@ -108,8 +108,8 @@ class NFSGanesha(RESTController):
         return status
 
 
-@ApiController('/nfs-ganesha/export', Scope.NFS_GANESHA)
-@ControllerDoc(group="NFS-Ganesha")
+@APIRouter('/nfs-ganesha/export', Scope.NFS_GANESHA)
+@APIDoc(group="NFS-Ganesha")
 class NFSGaneshaExports(RESTController):
     RESOURCE_ID = "cluster_id/export_id"
 
@@ -233,8 +233,8 @@ class NFSGaneshaExports(RESTController):
             ganesha_conf.reload_daemons(export.daemons)
 
 
-@ApiController('/nfs-ganesha/daemon', Scope.NFS_GANESHA)
-@ControllerDoc(group="NFS-Ganesha")
+@APIRouter('/nfs-ganesha/daemon', Scope.NFS_GANESHA)
+@APIDoc(group="NFS-Ganesha")
 class NFSGaneshaService(RESTController):
 
     @EndpointDoc("List NFS-Ganesha daemons information",
@@ -252,7 +252,7 @@ class NFSGaneshaService(RESTController):
         return result
 
 
-@UiApiController('/nfs-ganesha', Scope.NFS_GANESHA)
+@UIRouter('/nfs-ganesha', Scope.NFS_GANESHA)
 class NFSGaneshaUi(BaseController):
     @Endpoint('GET', '/cephx/clients')
     @ReadPermission
index a0c1d171d54093a5e498c5ef8c79a09854e53441..b93b850e022ce046e8fbb99e4738755b0954a3cd 100644 (file)
@@ -4,7 +4,7 @@ from functools import wraps
 
 from ..exceptions import DashboardException
 from ..services.orchestrator import OrchClient
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, ReadPermission, RESTController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, RESTController
 
 STATUS_SCHEMA = {
     "available": (bool, "Orchestrator status"),
@@ -35,8 +35,8 @@ def raise_if_no_orchestrator(features=None):
     return inner
 
 
-@ApiController('/orchestrator')
-@ControllerDoc("Orchestrator Management API", "Orchestrator")
+@APIRouter('/orchestrator')
+@APIDoc("Orchestrator Management API", "Orchestrator")
 class Orchestrator(RESTController):
 
     @Endpoint()
index ea5ccd0f809bc0b82403e531955a5f2c3163a58e..67db1ad1d9d3da5e2023551b4ca8964401b1ca03 100644 (file)
@@ -15,9 +15,9 @@ from ..services.ceph_service import CephService, SendCommandError
 from ..services.exception import handle_orchestrator_error, handle_send_command_error
 from ..services.orchestrator import OrchClient, OrchFeature
 from ..tools import str_to_bool
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, Endpoint, EndpointDoc, ReadPermission, RESTController, \
-    Task, UpdatePermission, allow_empty_body
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+    EndpointDoc, ReadPermission, RESTController, Task, UpdatePermission, \
+    allow_empty_body
 from .orchestrator import raise_if_no_orchestrator
 
 logger = logging.getLogger('controllers.osd')
@@ -50,8 +50,8 @@ def osd_task(name, metadata, wait_for=2.0):
     return Task("osd/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/osd', Scope.OSD)
-@ControllerDoc('OSD management API', 'OSD')
+@APIRouter('/osd', Scope.OSD)
+@APIDoc('OSD management API', 'OSD')
 class Osd(RESTController):
     def list(self):
         osds = self.get_osd_map()
@@ -395,8 +395,8 @@ class Osd(RESTController):
         return CephService.send_command('mon', 'device ls-by-daemon', who='osd.{}'.format(svc_id))
 
 
-@ApiController('/osd/flags', Scope.OSD)
-@ControllerDoc(group='OSD')
+@APIRouter('/osd/flags', Scope.OSD)
+@APIDoc(group='OSD')
 class OsdFlagsController(RESTController):
     @staticmethod
     def _osd_flags():
index 803c273ec1a4ba5003dc1cddcf969fa0f2cc8b71..0bd883366937142424fc3febaae14682a24f3256 100644 (file)
@@ -5,7 +5,7 @@ import cherrypy
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 PERF_SCHEMA = {
     "mon.a": ({
@@ -31,50 +31,50 @@ class PerfCounter(RESTController):
             raise cherrypy.HTTPError(404, "{0} not found".format(error))
 
 
-@ApiController('perf_counters/mds', Scope.CEPHFS)
-@ControllerDoc("Mds Perf Counters Management API", "MdsPerfCounter")
+@APIRouter('perf_counters/mds', Scope.CEPHFS)
+@APIDoc("Mds Perf Counters Management API", "MdsPerfCounter")
 class MdsPerfCounter(PerfCounter):
     service_type = 'mds'
 
 
-@ApiController('perf_counters/mon', Scope.MONITOR)
-@ControllerDoc("Mon Perf Counters Management API", "MonPerfCounter")
+@APIRouter('perf_counters/mon', Scope.MONITOR)
+@APIDoc("Mon Perf Counters Management API", "MonPerfCounter")
 class MonPerfCounter(PerfCounter):
     service_type = 'mon'
 
 
-@ApiController('perf_counters/osd', Scope.OSD)
-@ControllerDoc("OSD Perf Counters Management API", "OsdPerfCounter")
+@APIRouter('perf_counters/osd', Scope.OSD)
+@APIDoc("OSD Perf Counters Management API", "OsdPerfCounter")
 class OsdPerfCounter(PerfCounter):
     service_type = 'osd'
 
 
-@ApiController('perf_counters/rgw', Scope.RGW)
-@ControllerDoc("Rgw Perf Counters Management API", "RgwPerfCounter")
+@APIRouter('perf_counters/rgw', Scope.RGW)
+@APIDoc("Rgw Perf Counters Management API", "RgwPerfCounter")
 class RgwPerfCounter(PerfCounter):
     service_type = 'rgw'
 
 
-@ApiController('perf_counters/rbd-mirror', Scope.RBD_MIRRORING)
-@ControllerDoc("Rgw Mirroring Perf Counters Management API", "RgwMirrorPerfCounter")
+@APIRouter('perf_counters/rbd-mirror', Scope.RBD_MIRRORING)
+@APIDoc("Rgw Mirroring Perf Counters Management API", "RgwMirrorPerfCounter")
 class RbdMirrorPerfCounter(PerfCounter):
     service_type = 'rbd-mirror'
 
 
-@ApiController('perf_counters/mgr', Scope.MANAGER)
-@ControllerDoc("Mgr Perf Counters Management API", "MgrPerfCounter")
+@APIRouter('perf_counters/mgr', Scope.MANAGER)
+@APIDoc("Mgr Perf Counters Management API", "MgrPerfCounter")
 class MgrPerfCounter(PerfCounter):
     service_type = 'mgr'
 
 
-@ApiController('perf_counters/tcmu-runner', Scope.ISCSI)
-@ControllerDoc("Tcmu Runner Perf Counters Management API", "TcmuRunnerPerfCounter")
+@APIRouter('perf_counters/tcmu-runner', Scope.ISCSI)
+@APIDoc("Tcmu Runner Perf Counters Management API", "TcmuRunnerPerfCounter")
 class TcmuRunnerPerfCounter(PerfCounter):
     service_type = 'tcmu-runner'
 
 
-@ApiController('perf_counters')
-@ControllerDoc("Perf Counters Management API", "PerfCounters")
+@APIRouter('perf_counters')
+@APIDoc("Perf Counters Management API", "PerfCounters")
 class PerfCounters(RESTController):
     @EndpointDoc("Display Perf Counters",
                  responses={200: PERF_SCHEMA})
index 4fef2b975d4aa6fc6067cf50d8f4ba9fae59b18c..e2717e6fd47734d35f6eb3f0f40ded201b023fc4 100644 (file)
@@ -11,8 +11,8 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_send_command_error
 from ..services.rbd import RbdConfiguration
 from ..tools import TaskManager, str_to_bool
-from . import ApiController, ControllerDoc, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, Task, UiApiController
+from . import APIDoc, APIRouter, Endpoint, EndpointDoc, ReadPermission, \
+    RESTController, Task, UIRouter
 
 POOL_SCHEMA = ([{
     "pool": (int, "pool id"),
@@ -89,8 +89,8 @@ def pool_task(name, metadata, wait_for=2.0):
     return Task("pool/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/pool', Scope.POOL)
-@ControllerDoc("Get pool details by pool name", "Pool")
+@APIRouter('/pool', Scope.POOL)
+@APIDoc("Get pool details by pool name", "Pool")
 class Pool(RESTController):
 
     @staticmethod
@@ -283,8 +283,8 @@ class Pool(RESTController):
         return RbdConfiguration(pool_name).list()
 
 
-@UiApiController('/pool', Scope.POOL)
-@ControllerDoc("Dashboard UI helper function; not part of the public API", "PoolUi")
+@UIRouter('/pool', Scope.POOL)
+@APIDoc("Dashboard UI helper function; not part of the public API", "PoolUi")
 class PoolUi(Pool):
     @Endpoint()
     @ReadPermission
index 21bd151682bb001f8833ad9348b3af1602ec4ddc..f1dae7f1a2fa2d89b9b3e3e8176ab939343e7a69 100644 (file)
@@ -8,10 +8,10 @@ import requests
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..settings import Settings
-from . import ApiController, BaseController, Controller, ControllerDoc, Endpoint, RESTController
+from . import APIDoc, APIRouter, BaseController, Endpoint, RESTController, Router
 
 
-@Controller('/api/prometheus_receiver', secure=False)
+@Router('/api/prometheus_receiver', secure=False)
 class PrometheusReceiver(BaseController):
     """
     The receiver is needed in order to receive alert notifications (reports)
@@ -59,8 +59,8 @@ class PrometheusRESTController(RESTController):
         raise DashboardException(content, http_status_code=400, component='prometheus')
 
 
-@ApiController('/prometheus', Scope.PROMETHEUS)
-@ControllerDoc("Prometheus Management API", "Prometheus")
+@APIRouter('/prometheus', Scope.PROMETHEUS)
+@APIDoc("Prometheus Management API", "Prometheus")
 class Prometheus(PrometheusRESTController):
     def list(self, **params):
         return self.alert_proxy('GET', '/alerts', params)
@@ -82,8 +82,8 @@ class Prometheus(PrometheusRESTController):
         return self.alert_proxy('DELETE', '/silence/' + s_id) if s_id else None
 
 
-@ApiController('/prometheus/notifications', Scope.PROMETHEUS)
-@ControllerDoc("Prometheus Notifications Management API", "PrometheusNotifications")
+@APIRouter('/prometheus/notifications', Scope.PROMETHEUS)
+@APIDoc("Prometheus Notifications Management API", "PrometheusNotifications")
 class PrometheusNotifications(RESTController):
 
     def list(self, **params):
index a1fa9be3bdb8b6fb0c23cb58409c0e3bce8b6d5f..252ddfc61c1ba36845a2c38f5e6ee821027966f6 100644 (file)
@@ -18,9 +18,8 @@ from ..services.rbd import RbdConfiguration, RbdService, RbdSnapshotService, \
     format_bitmask, format_features, parse_image_spec, rbd_call, \
     rbd_image_call
 from ..tools import ViewCache, str_to_bool
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, EndpointDoc, RESTController, Task, UpdatePermission, \
-    allow_empty_body
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, \
+    EndpointDoc, RESTController, Task, UpdatePermission, allow_empty_body
 
 logger = logging.getLogger(__name__)
 
@@ -66,8 +65,8 @@ def _sort_features(features, enable=True):
     features.sort(key=key_func, reverse=not enable)
 
 
-@ApiController('/block/image', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Management API", "Rbd")
+@APIRouter('/block/image', Scope.RBD_IMAGE)
+@APIDoc("RBD Management API", "Rbd")
 class Rbd(RESTController):
 
     # set of image features that can be enable on existing images
@@ -264,8 +263,8 @@ class Rbd(RESTController):
         return rbd_call(pool_name, namespace, rbd_inst.trash_move, image_name, delay)
 
 
-@ApiController('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Snapshot Management API", "RbdSnapshot")
+@APIRouter('/block/image/{image_spec}/snap', Scope.RBD_IMAGE)
+@APIDoc("RBD Snapshot Management API", "RbdSnapshot")
 class RbdSnapshot(RESTController):
 
     RESOURCE_ID = "snapshot_name"
@@ -355,8 +354,8 @@ class RbdSnapshot(RESTController):
         rbd_call(pool_name, namespace, _parent_clone)
 
 
-@ApiController('/block/image/trash', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Trash Management API", "RbdTrash")
+@APIRouter('/block/image/trash', Scope.RBD_IMAGE)
+@APIDoc("RBD Trash Management API", "RbdTrash")
 class RbdTrash(RESTController):
     RESOURCE_ID = "image_id_spec"
 
@@ -447,8 +446,8 @@ class RbdTrash(RESTController):
                         int(str_to_bool(force)))
 
 
-@ApiController('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
-@ControllerDoc("RBD Namespace Management API", "RbdNamespace")
+@APIRouter('/block/pool/{pool_name}/namespace', Scope.RBD_IMAGE)
+@APIDoc("RBD Namespace Management API", "RbdNamespace")
 class RbdNamespace(RESTController):
 
     def __init__(self):
index b2bc86b4adc1b1c39de6eb4c8c0f895c304ea77f..f5bda8c8926f23d870291fdf6bf4af1f1f9001da 100644 (file)
@@ -15,9 +15,8 @@ from ..services.ceph_service import CephService
 from ..services.exception import handle_rados_error, handle_rbd_error, serialize_dashboard_exception
 from ..services.rbd import rbd_call
 from ..tools import ViewCache
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, Task, UpdatePermission, \
-    allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, Task, UpdatePermission, allow_empty_body
 
 logger = logging.getLogger('controllers.rbd_mirror')
 
@@ -367,8 +366,8 @@ RBDM_SUMMARY_SCHEMA = {
 }
 
 
-@ApiController('/block/mirroring', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Management API", "RbdMirroring")
+@APIRouter('/block/mirroring', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Management API", "RbdMirroring")
 class RbdMirroring(BaseController):
 
     @Endpoint(method='GET', path='site_name')
@@ -390,8 +389,8 @@ class RbdMirroring(BaseController):
         return {'site_name': rbd.RBD().mirror_site_name_get(mgr.rados)}
 
 
-@ApiController('/block/mirroring/summary', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Summary Management API", "RbdMirroringSummary")
+@APIRouter('/block/mirroring/summary', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Summary Management API", "RbdMirroringSummary")
 class RbdMirroringSummary(BaseController):
 
     @Endpoint()
@@ -408,8 +407,8 @@ class RbdMirroringSummary(BaseController):
                 'content_data': content_data}
 
 
-@ApiController('/block/mirroring/pool', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Mode Management API", "RbdMirroringPoolMode")
+@APIRouter('/block/mirroring/pool', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Mode Management API", "RbdMirroringPoolMode")
 class RbdMirroringPoolMode(RESTController):
 
     RESOURCE_ID = "pool_name"
@@ -450,9 +449,8 @@ class RbdMirroringPoolMode(RESTController):
         return rbd_call(pool_name, None, _edit, mirror_mode)
 
 
-@ApiController('/block/mirroring/pool/{pool_name}/bootstrap',
-               Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Bootstrap Management API", "RbdMirroringPoolBootstrap")
+@APIRouter('/block/mirroring/pool/{pool_name}/bootstrap', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Bootstrap Management API", "RbdMirroringPoolBootstrap")
 class RbdMirroringPoolBootstrap(BaseController):
 
     @Endpoint(method='POST', path='token')
@@ -484,8 +482,8 @@ class RbdMirroringPoolBootstrap(BaseController):
         return {}
 
 
-@ApiController('/block/mirroring/pool/{pool_name}/peer', Scope.RBD_MIRRORING)
-@ControllerDoc("RBD Mirroring Pool Peer Management API", "RbdMirroringPoolPeer")
+@APIRouter('/block/mirroring/pool/{pool_name}/peer', Scope.RBD_MIRRORING)
+@APIDoc("RBD Mirroring Pool Peer Management API", "RbdMirroringPoolPeer")
 class RbdMirroringPoolPeer(RESTController):
 
     RESOURCE_ID = "peer_uuid"
index 86694b92b0d94bf83933a6667b2e01b5d9341d6b..640a531f749b20ad1d9f5929bf18a8ed22320c87 100644 (file)
@@ -12,8 +12,8 @@ from ..services.auth import AuthManager, JwtManager
 from ..services.ceph_service import CephService
 from ..services.rgw_client import NoRgwDaemonsException, RgwClient
 from ..tools import json_str_to_object, str_to_bool
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, ReadPermission, RESTController, allow_empty_body
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    ReadPermission, RESTController, allow_empty_body
 
 try:
     from typing import Any, List, Optional
@@ -40,8 +40,8 @@ RGW_USER_SCHEMA = {
 }
 
 
-@ApiController('/rgw', Scope.RGW)
-@ControllerDoc("RGW Management API", "Rgw")
+@APIRouter('/rgw', Scope.RGW)
+@APIDoc("RGW Management API", "Rgw")
 class Rgw(BaseController):
     @Endpoint()
     @ReadPermission
@@ -78,8 +78,8 @@ class Rgw(BaseController):
         return status
 
 
-@ApiController('/rgw/daemon', Scope.RGW)
-@ControllerDoc("RGW Daemon Management API", "RgwDaemon")
+@APIRouter('/rgw/daemon', Scope.RGW)
+@APIDoc("RGW Daemon Management API", "RgwDaemon")
 class RgwDaemon(RESTController):
     @EndpointDoc("Display RGW Daemons",
                  responses={200: [RGW_DAEMON_SCHEMA]})
@@ -149,8 +149,8 @@ class RgwRESTController(RESTController):
             raise DashboardException(e, http_status_code=http_status_code, component='rgw')
 
 
-@ApiController('/rgw/site', Scope.RGW)
-@ControllerDoc("RGW Site Management API", "RgwSite")
+@APIRouter('/rgw/site', Scope.RGW)
+@APIDoc("RGW Site Management API", "RgwSite")
 class RgwSite(RgwRESTController):
     def list(self, query=None, daemon_name=None):
         if query == 'placement-targets':
@@ -162,8 +162,8 @@ class RgwSite(RgwRESTController):
         raise DashboardException(http_status_code=501, component='rgw', msg='Not Implemented')
 
 
-@ApiController('/rgw/bucket', Scope.RGW)
-@ControllerDoc("RGW Bucket Management API", "RgwBucket")
+@APIRouter('/rgw/bucket', Scope.RGW)
+@APIDoc("RGW Bucket Management API", "RgwBucket")
 class RgwBucket(RgwRESTController):
     def _append_bid(self, bucket):
         """
@@ -324,8 +324,8 @@ class RgwBucket(RgwRESTController):
         }, json_response=False)
 
 
-@ApiController('/rgw/user', Scope.RGW)
-@ControllerDoc("RGW User Management API", "RgwUser")
+@APIRouter('/rgw/user', Scope.RGW)
+@APIDoc("RGW User Management API", "RgwUser")
 class RgwUser(RgwRESTController):
     def _append_uid(self, user):
         """
index 44dcef75c765e9b1f1595962d61a09f7f66d6d80..cdd73ddf1be074133232f8b19cb65f8a6eb5f425 100644 (file)
@@ -8,8 +8,7 @@ from ..exceptions import DashboardException, RoleAlreadyExists, \
 from ..security import Permission
 from ..security import Scope as SecurityScope
 from ..services.access_control import SYSTEM_ROLES
-from . import ApiController, ControllerDoc, CreatePermission, EndpointDoc, \
-    RESTController, UiApiController
+from . import APIDoc, APIRouter, CreatePermission, EndpointDoc, RESTController, UIRouter
 
 ROLE_SCHEMA = [{
     "name": (str, "Role Name"),
@@ -21,8 +20,8 @@ ROLE_SCHEMA = [{
 }]
 
 
-@ApiController('/role', SecurityScope.USER)
-@ControllerDoc("Role Management API", "Role")
+@APIRouter('/role', SecurityScope.USER)
+@APIDoc("Role Management API", "Role")
 class Role(RESTController):
     @staticmethod
     def _role_to_dict(role):
@@ -138,7 +137,7 @@ class Role(RESTController):
                             role.get('scopes_permissions'))
 
 
-@UiApiController('/scope', SecurityScope.USER)
+@UIRouter('/scope', SecurityScope.USER)
 class Scope(RESTController):
     def list(self):
         return SecurityScope.all_scopes()
index 8939f6827243b317260df204abccfe6fd30f5c39..55445bc94d7b2cc6d174b8745418232785c73209 100644 (file)
@@ -15,10 +15,10 @@ from .. import mgr
 from ..exceptions import UserDoesNotExist
 from ..services.auth import JwtManager
 from ..tools import prepare_url_prefix
-from . import BaseController, Controller, ControllerAuthMixin, Endpoint, allow_empty_body
+from . import BaseController, ControllerAuthMixin, Endpoint, Router, allow_empty_body
 
 
-@Controller('/auth/saml2', secure=False)
+@Router('/auth/saml2', secure=False)
 class Saml2(BaseController, ControllerAuthMixin):
 
     @staticmethod
index b3c3ab94798bf4c4788ec29cb68057c7823fb74b..d3ba882a1d92633ab143fc86df0008f0751e6657 100644 (file)
@@ -7,8 +7,8 @@ from ..exceptions import DashboardException
 from ..security import Scope
 from ..services.exception import handle_orchestrator_error
 from ..services.orchestrator import OrchClient, OrchFeature
-from . import ApiController, ControllerDoc, CreatePermission, \
-    DeletePermission, Endpoint, ReadPermission, RESTController, Task
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+    ReadPermission, RESTController, Task
 from .orchestrator import raise_if_no_orchestrator
 
 
@@ -16,8 +16,8 @@ def service_task(name, metadata, wait_for=2.0):
     return Task("service/{}".format(name), metadata, wait_for)
 
 
-@ApiController('/service', Scope.HOSTS)
-@ControllerDoc("Service Management API", "Service")
+@APIRouter('/service', Scope.HOSTS)
+@APIDoc("Service Management API", "Service")
 class Service(RESTController):
 
     @Endpoint()
index 77d8eeb0ad7d76107c317053432fd97678e16650..7e81a0822d1d793fd5c78412d503f689f73b89f0 100644 (file)
@@ -6,7 +6,7 @@ import cherrypy
 from ..security import Scope
 from ..settings import Options
 from ..settings import Settings as SettingsModule
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController, UiApiController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController, UIRouter
 
 SETTINGS_SCHEMA = [{
     "name": (str, 'Settings Name'),
@@ -16,8 +16,8 @@ SETTINGS_SCHEMA = [{
 }]
 
 
-@ApiController('/settings', Scope.CONFIG_OPT)
-@ControllerDoc("Settings Management API", "Settings")
+@APIRouter('/settings', Scope.CONFIG_OPT)
+@APIDoc("Settings Management API", "Settings")
 class Settings(RESTController):
     """
     Enables to manage the settings of the dashboard (not the Ceph cluster).
@@ -102,7 +102,7 @@ class Settings(RESTController):
                 setattr(SettingsModule, self._to_native(name), value)
 
 
-@UiApiController('/standard_settings')
+@UIRouter('/standard_settings')
 class StandardSettings(RESTController):
     def list(self):
         """
index becb91295f6a02a139e460ef9fc1dba43e1a4a88..9da48220808dd16566f0a859ad91551e9bb2c348 100644 (file)
@@ -8,7 +8,7 @@ from ..exceptions import ViewCacheNoDataException
 from ..security import Permission, Scope
 from ..services import progress
 from ..tools import TaskManager
-from . import ApiController, BaseController, ControllerDoc, Endpoint, EndpointDoc
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc
 
 SUMMARY_SCHEMA = {
     "health_status": (str, ""),
@@ -37,8 +37,8 @@ SUMMARY_SCHEMA = {
 }
 
 
-@ApiController('/summary')
-@ControllerDoc("Get Ceph Summary Details", "Summary")
+@APIRouter('/summary')
+@APIDoc("Get Ceph Summary Details", "Summary")
 class Summary(BaseController):
     def _health_status(self):
         health_data = mgr.get("health")
index 9b477a659d2004a098a4a0ef8679ce570e441d7c..d5fbc34a766ecca3ca61ed00072e6e8eace60f65 100644 (file)
@@ -2,7 +2,7 @@
 
 from ..services import progress
 from ..tools import TaskManager
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 TASK_SCHEMA = {
     "executing_tasks": (str, "ongoing executing tasks"),
@@ -22,8 +22,8 @@ TASK_SCHEMA = {
 }
 
 
-@ApiController('/task')
-@ControllerDoc("Task Management API", "Task")
+@APIRouter('/task')
+@APIDoc("Task Management API", "Task")
 class Task(RESTController):
     @EndpointDoc("Display Tasks",
                  parameters={
index 37ab98f1a56c811757033a03246ddf01d8f09a9e..03f34592b1cf214282202da69260419abccd8389 100644 (file)
@@ -3,7 +3,7 @@
 from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
-from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+from . import APIDoc, APIRouter, EndpointDoc, RESTController
 
 REPORT_SCHEMA = {
     "report": ({
@@ -200,8 +200,8 @@ REPORT_SCHEMA = {
 }
 
 
-@ApiController('/telemetry', Scope.CONFIG_OPT)
-@ControllerDoc("Display Telemetry Report", "Telemetry")
+@APIRouter('/telemetry', Scope.CONFIG_OPT)
+@APIDoc("Display Telemetry Report", "Telemetry")
 class Telemetry(RESTController):
 
     @RESTController.Collection('GET')
index dbd240f7c54101bf7511d2e47962a9ff0ef5bfda..9141cfe68756df0cdaf09e90abc0cfa04406f2b9 100644 (file)
@@ -12,8 +12,8 @@ from ..exceptions import DashboardException, PasswordPolicyException, \
 from ..security import Scope
 from ..services.access_control import SYSTEM_ROLES, PasswordPolicy
 from ..services.auth import JwtManager
-from . import ApiController, BaseController, ControllerDoc, Endpoint, \
-    EndpointDoc, RESTController, allow_empty_body, validate_ceph_type
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
+    RESTController, allow_empty_body, validate_ceph_type
 
 USER_SCHEMA = ([{
     "username": (str, 'Username of the user'),
@@ -46,8 +46,8 @@ def validate_password_policy(password, username=None, old_password=None):
                                  component='user')
 
 
-@ApiController('/user', Scope.USER)
-@ControllerDoc("Display User Details", "User")
+@APIRouter('/user', Scope.USER)
+@APIDoc("Display User Details", "User")
 class User(RESTController):
 
     @staticmethod
@@ -157,8 +157,8 @@ class User(RESTController):
         return User._user_to_dict(user)
 
 
-@ApiController('/user')
-@ControllerDoc("Get User Password Policy Details", "UserPasswordPolicy")
+@APIRouter('/user')
+@APIDoc("Get User Password Policy Details", "UserPasswordPolicy")
 class UserPasswordPolicy(RESTController):
 
     @Endpoint('POST')
@@ -190,8 +190,8 @@ class UserPasswordPolicy(RESTController):
         return result
 
 
-@ApiController('/user/{username}')
-@ControllerDoc("Change User Password", "UserChangePassword")
+@APIRouter('/user/{username}')
+@APIDoc("Change User Password", "UserChangePassword")
 class UserChangePassword(BaseController):
 
     @Endpoint('POST')
index 71551ed14e0ba89a466697f6d7e399511dc37ed3..9b9e028d6947c19ac5d5877e7ae85489cc509a95 100644 (file)
@@ -25,7 +25,7 @@ from mgr_util import ServerConfigException, build_url, \
     create_self_signed_cert, get_default_addr, verify_tls_files
 
 from . import mgr
-from .controllers import generate_routes, json_error_page
+from .controllers import Router, json_error_page
 from .grafana import push_local_dashboards
 from .model.feedback import Feedback
 from .rest_client import RequestException
@@ -339,7 +339,7 @@ class Module(MgrModule, CherryPyConfig):
         # about to start serving
         self.set_uri(uri)
 
-        mapper, parent_urls = generate_routes(self.url_prefix)
+        mapper, parent_urls = Router.generate_routes(self.url_prefix)
 
         config = {}
         for purl in parent_urls:
index ff2dea0e99a38b5ee86156305898a27c3f35fc5f..844457b0a72d3902310b5b34d12b3885bd7262dd 100644 (file)
@@ -2311,12 +2311,21 @@ paths:
             application/vnd.ceph.api.v2.0+json:
               schema:
                 properties:
+                  max_size:
+                    description: Maximum size of Rule
+                    type: integer
+                  min_size:
+                    description: Minimum size of Rule
+                    type: integer
                   rule_id:
                     description: Rule ID
                     type: integer
                   rule_name:
                     description: Rule Name
                     type: string
+                  ruleset:
+                    description: RuleSet related to the rule
+                    type: integer
                   steps:
                     description: Steps included in the rule
                     items:
@@ -2328,7 +2337,10 @@ paths:
                 required:
                 - rule_id
                 - rule_name
+                - ruleset
                 - type
+                - min_size
+                - max_size
                 - steps
                 type: object
           description: OK
@@ -2644,6 +2656,85 @@ paths:
       summary: Get List Of Features
       tags:
       - FeatureTogglesEndpoint
+  /api/feedback:
+    post:
+      description: "\n        Create an issue.\n        :param project: The affected\
+        \ ceph component.\n        :param tracker: The tracker type.\n        :param\
+        \ subject: The title of the issue.\n        :param description: The description\
+        \ of the issue.\n        "
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                description:
+                  type: string
+                project:
+                  type: string
+                subject:
+                  type: string
+                tracker:
+                  type: string
+              required:
+              - project
+              - tracker
+              - subject
+              - description
+              type: object
+      responses:
+        '201':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource created.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - Report
+  /api/feedback/{issue_number}:
+    get:
+      description: "\n        Fetch issue details.\n        :param issueAPI: The issue\
+        \ tracker API access key.\n        "
+      parameters:
+      - in: path
+        name: issue_number
+        required: true
+        schema:
+          type: integer
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - Report
   /api/grafana/dashboards:
     post:
       parameters: []
@@ -10361,6 +10452,8 @@ tags:
   name: RbdSnapshot
 - description: RBD Trash Management API
   name: RbdTrash
+- description: Feedback API
+  name: Report
 - description: RGW Management API
   name: Rgw
 - description: RGW Bucket Management API
index 1811be258a788f3e63f2e031e0a35dbdbde98d38..48d77e6cf1fd03ce69bd3cc363ff40e1b3fe04f4 100644 (file)
@@ -132,7 +132,7 @@ class FeatureToggles(I.CanMgr, I.Setupable, I.HasOptions,
 
     @PM.add_hook
     def get_controllers(self):
-        from ..controllers import ApiController, ControllerDoc, EndpointDoc, RESTController
+        from ..controllers import APIDoc, APIRouter, EndpointDoc, RESTController
 
         FEATURES_SCHEMA = {
             "rbd": (bool, ''),
@@ -143,8 +143,8 @@ class FeatureToggles(I.CanMgr, I.Setupable, I.HasOptions,
             "nfs": (bool, '')
         }
 
-        @ApiController('/feature_toggles')
-        @ControllerDoc("Manage Features API", "FeatureTogglesEndpoint")
+        @APIRouter('/feature_toggles')
+        @APIDoc("Manage Features API", "FeatureTogglesEndpoint")
         class FeatureTogglesEndpoint(RESTController):
             @EndpointDoc("Get List Of Features",
                          responses={200: FEATURES_SCHEMA})
index 0600135ac231cc7ad0489c1cee4fb25df7122920..22d6a294a39444550191b427c3863467985644aa 100644 (file)
@@ -79,9 +79,9 @@ class Motd(SP):
 
     @PM.add_hook
     def get_controllers(self):
-        from ..controllers import RESTController, UiApiController
+        from ..controllers import RESTController, UIRouter
 
-        @UiApiController('/motd')
+        @UIRouter('/motd')
         class MessageOfTheDay(RESTController):
             def list(_) -> Optional[Dict]:  # pylint: disable=no-self-argument
                 value: str = self.get_option(self.NAME)
index 198e5502f488a83b5b8c352de3a3e86b2dfd831f..9e73b85d608b9cf5561af09de7b63b3db86e328d 100644 (file)
@@ -14,7 +14,8 @@ from mgr_module import HandleCommandResult
 from pyfakefs import fake_filesystem
 
 from .. import mgr
-from ..controllers import APIVersion, generate_controller_routes, json_error_page
+from ..controllers import generate_controller_routes, json_error_page
+from ..controllers._version import APIVersion
 from ..module import Module
 from ..plugins import PLUGIN_MANAGER, debug, feature_toggles  # noqa
 from ..services.auth import AuthManagerTool
index 71121a06bcaf15478343dea9a026cc706773c64d..de6e7cdc5f784e3edcdb7a599528d645b51ad5ce 100644 (file)
@@ -9,12 +9,12 @@ except ImportError:
     import unittest.mock as mock
 
 from .. import mgr
-from ..controllers import Controller, RESTController
+from ..controllers import RESTController, Router
 from . import ControllerTestCase, KVStoreMockMixin  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('/foo', secure=False)
+@Router('/foo', secure=False)
 class FooResource(RESTController):
     def create(self, password):
         pass
index 7bd0b1955a0612aef684a4f28ef38bb6c751002f..8daa2dd0ea51d7c10083b00b96dcd723d5ceadd7 100644 (file)
@@ -1,10 +1,10 @@
 # -*- coding: utf-8 -*-
 
-from ..controllers import ApiController, BaseController, Controller, Endpoint, RESTController
+from ..controllers import APIRouter, BaseController, Endpoint, RESTController, Router
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@Controller("/btest/{key}", base_url="/ui", secure=False)
+@Router("/btest/{key}", base_url="/ui", secure=False)
 class BTest(BaseController):
     @Endpoint()
     def test1(self, key, opt=1):
@@ -36,7 +36,7 @@ class BTest(BaseController):
         return {'key': key, 'opt': opt}
 
 
-@ApiController("/rtest/{key}", secure=False)
+@APIRouter("/rtest/{key}", secure=False)
 class RTest(RESTController):
     RESOURCE_ID = 'skey/ekey'
 
@@ -70,7 +70,7 @@ class RTest(RESTController):
         return {'key': key, 'skey': skey, 'ekey': ekey, 'opt': opt}
 
 
-@Controller("/", secure=False)
+@Router("/", secure=False)
 class Root(BaseController):
     @Endpoint(json_response=False)
     def __call__(self):
index a49305fd29edaedeba58af48797df324a6bdcdf6..3610144b2cc3e0944c7e401a4d47d74864b0febe 100644 (file)
@@ -3,15 +3,15 @@
 import unittest
 
 from ..api.doc import SchemaType
-from ..controllers import ApiController, APIVersion, ControllerDoc, Endpoint, \
-    EndpointDoc, RESTController
+from ..controllers import APIDoc, APIRouter, Endpoint, EndpointDoc, RESTController
+from ..controllers._version import APIVersion
 from ..controllers.docs import Docs
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # Dummy controller and endpoint that can be assigned with @EndpointDoc and @GroupDoc
-@ControllerDoc("Group description", group="FooGroup")
-@ApiController("/doctest/", secure=False)
+@APIDoc("Group description", group="FooGroup")
+@APIRouter("/doctest/", secure=False)
 class DecoratedController(RESTController):
     RESOURCE_ID = 'doctest'
 
index f28e2cd55d4dfe440c47694a3218f80a20e0165e..8e20bfb2373634685e2c329a57dacc797bbcc851 100644 (file)
@@ -4,7 +4,7 @@ import time
 
 import rados
 
-from ..controllers import Controller, Endpoint, RESTController, Task
+from ..controllers import Endpoint, RESTController, Router, Task
 from ..services.ceph_service import SendCommandError
 from ..services.exception import handle_rados_error, \
     handle_send_command_error, serialize_dashboard_exception
@@ -13,7 +13,7 @@ from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('foo', secure=False)
+@Router('foo', secure=False)
 class FooResource(RESTController):
 
     @Endpoint()
index 7a36269d87becaed4315ccafda976e39d47de7df..fce7c60720ea5cc6b1001db27e1c6285d4951f68 100644 (file)
@@ -22,8 +22,8 @@ class SettingsTest(unittest.TestCase, KVStoreMockMixin):
         cls.mgr = mgr
 
         # Populate real endpoint map
-        from ..controllers import load_controllers
-        cls.controllers = load_controllers()
+        from ..controllers import BaseController
+        cls.controllers = BaseController.load_controllers()
 
         # Initialize FeatureToggles plugin
         cls.plugin = FeatureToggles()
index 56a117f048d0306daa40e776014f15210e974ad8..8b1e03b0ce31721262350b2a75b4c38977996d8f 100644 (file)
@@ -6,7 +6,7 @@ from unittest import mock
 from orchestrator import HostSpec, InventoryHost
 
 from .. import mgr
-from ..controllers import APIVersion
+from ..controllers._version import APIVersion
 from ..controllers.host import Host, HostUi, get_device_osd_map, get_hosts, get_inventories
 from ..tools import NotificationQueue, TaskManager
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
index b4abbdea36b97cde32509d8c8532acc597bf4586..b2bf7091f87b3c3185435f7b493df0562e949073 100644 (file)
@@ -7,14 +7,14 @@ try:
 except ImportError:
     import unittest.mock as mock
 
-from ..controllers import Controller, RESTController, Task
+from ..controllers import RESTController, Router, Task
 from ..controllers.task import Task as TaskController
 from ..services import progress
 from ..tools import NotificationQueue, TaskManager
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@Controller('/test/task', secure=False)
+@Router('/test/task', secure=False)
 class TaskTest(RESTController):
     sleep_time = 0.0
 
index fec42d66783355d15266ebb3c6ac7576c8ba4455..51f9216a19c4dd7ac6f70da5ae576d5a19622705 100644 (file)
@@ -10,15 +10,15 @@ try:
 except ImportError:
     from unittest.mock import patch
 
-from ..controllers import ApiController, APIVersion, BaseController, \
-    Controller, Proxy, RESTController
+from ..controllers import APIRouter, BaseController, Proxy, RESTController, Router
+from ..controllers._version import APIVersion
 from ..services.exception import handle_rados_error
 from ..tools import dict_contains_path, dict_get, json_str_to_object, partial_dict
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
 # pylint: disable=W0613
-@Controller('/foo', secure=False)
+@Router('/foo', secure=False)
 class FooResource(RESTController):
     elems = []
 
@@ -43,20 +43,20 @@ class FooResource(RESTController):
         return dict(key=key, newdata=newdata)
 
 
-@Controller('/foo/:key/:method', secure=False)
+@Router('/foo/:key/:method', secure=False)
 class FooResourceDetail(RESTController):
     def list(self, key, method):
         return {'detail': (key, [method])}
 
 
-@ApiController('/rgw/proxy', secure=False)
+@APIRouter('/rgw/proxy', secure=False)
 class GenerateControllerRoutesController(BaseController):
     @Proxy()
     def __call__(self, path, **params):
         pass
 
 
-@ApiController('/fooargs', secure=False)
+@APIRouter('/fooargs', secure=False)
 class FooArgs(RESTController):
     def set(self, code, name=None, opt1=None, opt2=None):
         return {'code': code, 'name': name, 'opt1': opt1, 'opt2': opt2}
index b6d6d0c2ef502e1ae0cc029af790825f20e8f84c..85804b00ba21ba976b89cc53efc7ae2520245e7a 100644 (file)
@@ -2,11 +2,13 @@
 
 import unittest
 
-from ..controllers import ApiController, APIVersion, RESTController
+from ..controllers._api_router import APIRouter
+from ..controllers._rest_controller import RESTController
+from ..controllers._version import APIVersion
 from . import ControllerTestCase  # pylint: disable=no-name-in-module
 
 
-@ApiController("/vtest", secure=False)
+@APIRouter("/vtest", secure=False)
 class VTest(RESTController):
     RESOURCE_ID = "vid"