From 204109cc82ed76b73ca6abaa5e923c8e0a89885d Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Tue, 3 Apr 2018 17:02:09 +0100 Subject: [PATCH] mgr/dashboard: using RoutesDispatcher as HTTP request dispatcher Signed-off-by: Ricardo Dias --- ceph.spec.in | 4 + .../mgr/dashboard/controllers/monitor.py | 2 +- src/pybind/mgr/dashboard/controllers/rbd.py | 1 + .../dashboard/controllers/rbd_mirroring.py | 3 +- .../mgr/dashboard/controllers/summary.py | 2 +- src/pybind/mgr/dashboard/module.py | 73 +--- src/pybind/mgr/dashboard/requirements.txt | 1 + src/pybind/mgr/dashboard/tests/helper.py | 15 +- .../mgr/dashboard/tests/test_rbd_mirroring.py | 5 +- .../mgr/dashboard/tests/test_tcmu_iscsi.py | 6 +- src/pybind/mgr/dashboard/tests/test_tools.py | 33 +- src/pybind/mgr/dashboard/tools.py | 398 +++++++++++------- 12 files changed, 310 insertions(+), 233 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index 18269d8eaf5..a0f0c54bdd6 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -219,6 +219,7 @@ BuildRequires: python3-Cython %if 0%{with make_check} %if 0%{?fedora} || 0%{?rhel} BuildRequires: python%{_python_buildid}-cherrypy +BuildRequires: python%{_python_buildid}-routes BuildRequires: python%{_python_buildid}-pecan BuildRequires: python%{_python_buildid}-werkzeug BuildRequires: python%{_python_buildid}-tox @@ -232,6 +233,7 @@ BuildRequires: py-bcrypt %endif %if 0%{?suse_version} BuildRequires: python%{_python_buildid}-CherryPy +BuildRequires: python%{_python_buildid}-Routes BuildRequires: python%{_python_buildid}-Werkzeug BuildRequires: python%{_python_buildid}-pecan BuildRequires: python%{_python_buildid}-numpy-devel @@ -358,6 +360,7 @@ Group: System/Filesystems Requires: ceph-base = %{_epoch_prefix}%{version}-%{release} %if 0%{?fedora} || 0%{?rhel} Requires: python%{_python_buildid}-cherrypy +Requires: python%{_python_buildid}-routes Requires: python%{_python_buildid}-jinja2 Requires: python%{_python_buildid}-pecan Requires: python%{_python_buildid}-werkzeug @@ -371,6 +374,7 @@ Requires: py-bcrypt %endif %if 0%{?suse_version} Requires: python%{_python_buildid}-CherryPy +Requires: python%{_python_buildid}-Routes Requires: python%{_python_buildid}-Jinja2 Requires: python%{_python_buildid}-Werkzeug Requires: python%{_python_buildid}-pecan diff --git a/src/pybind/mgr/dashboard/controllers/monitor.py b/src/pybind/mgr/dashboard/controllers/monitor.py index b9733fee7fc..3a57f55b558 100644 --- a/src/pybind/mgr/dashboard/controllers/monitor.py +++ b/src/pybind/mgr/dashboard/controllers/monitor.py @@ -14,7 +14,7 @@ from ..tools import ApiController, AuthRequired, BaseController class Monitor(BaseController): @cherrypy.expose @cherrypy.tools.json_out() - def default(self, *_vpath, **_params): + def __call__(self): in_quorum, out_quorum = [], [] counters = ['mon.num_sessions'] diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index 4f877980d16..585d2087405 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -26,6 +26,7 @@ class Rbd(RESTController): } def __init__(self): + super(Rbd, self).__init__() self.rbd = None @staticmethod diff --git a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py index 2e799817cb2..118183160ea 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py @@ -157,11 +157,12 @@ def get_daemons_and_pools(): # pylint: disable=R0915 class RbdMirror(BaseController): def __init__(self): + super(RbdMirror, self).__init__() self.pool_data = {} @cherrypy.expose @cherrypy.tools.json_out() - def default(self, *_vpath, **_params): + def __call__(self): status, content_data = self._get_content_data() return {'status': status, 'content_data': content_data} diff --git a/src/pybind/mgr/dashboard/controllers/summary.py b/src/pybind/mgr/dashboard/controllers/summary.py index 0f92d8f6bbd..002f2b71c18 100644 --- a/src/pybind/mgr/dashboard/controllers/summary.py +++ b/src/pybind/mgr/dashboard/controllers/summary.py @@ -58,7 +58,7 @@ class Summary(BaseController): @cherrypy.expose @cherrypy.tools.json_out() - def default(self, *_vpath, **_params): + def __call__(self): executing_t, finished_t = TaskManager.list_serializable() return { 'rbd_pools': self._rbd_pool_data(), diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index e8bbea3fce5..9221d8a4288 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -27,7 +27,7 @@ if 'COVERAGE_ENABLED' in os.environ: # pylint: disable=wrong-import-position from . import logger, mgr from .controllers.auth import Auth -from .tools import load_controllers, json_error_page, SessionExpireAtBrowserCloseTool, \ +from .tools import generate_routes, json_error_page, SessionExpireAtBrowserCloseTool, \ NotificationQueue, RequestLoggingTool, TaskManager from .settings import options_command_list, handle_option_command @@ -97,6 +97,7 @@ class Module(MgrModule): return os.path.join(current_dir, 'frontend/dist') def configure_cherrypy(self): + # pylint: disable=too-many-locals server_addr = self.get_localized_config('server_addr', '::') server_port = self.get_localized_config('server_port', '8080') if server_addr is None: @@ -125,14 +126,6 @@ class Module(MgrModule): } cherrypy.config.update(config) - config = { - '/': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': self.get_frontend_path(), - 'tools.staticdir.index': 'index.html' - } - } - # Publish the URI that others may use to access the service we're # about to start serving self.set_uri("http://{0}:{1}{2}/".format( @@ -141,8 +134,17 @@ class Module(MgrModule): self.url_prefix )) - cherrypy.tree.mount(Module.ApiRoot(self), '{}/api'.format(self.url_prefix)) - cherrypy.tree.mount(Module.StaticRoot(), '{}/'.format(self.url_prefix), config=config) + mapper = generate_routes(self.url_prefix) + + config = { + '{}/'.format(self.url_prefix): { + 'tools.staticdir.on': True, + 'tools.staticdir.dir': self.get_frontend_path(), + 'tools.staticdir.index': 'index.html' + }, + '{}/api'.format(self.url_prefix): {'request.dispatch': mapper} + } + cherrypy.tree.mount(None, config=config) def serve(self): if 'COVERAGE_ENABLED' in os.environ: @@ -183,55 +185,6 @@ class Module(MgrModule): def notify(self, notify_type, notify_id): NotificationQueue.new_notification(notify_type, notify_id) - class ApiRoot(object): - - _cp_config = { - 'tools.sessions.on': True, - 'tools.authenticate.on': True - } - - def __init__(self, mgrmod): - self.ctrls = load_controllers() - logger.debug('Loaded controllers: %s', self.ctrls) - - first_level_ctrls = [ctrl for ctrl in self.ctrls - if '/' not in ctrl._cp_path_] - multi_level_ctrls = set(self.ctrls).difference(first_level_ctrls) - - for ctrl in first_level_ctrls: - logger.info('Adding controller: %s -> /api/%s', ctrl.__name__, - ctrl._cp_path_) - inst = ctrl() - setattr(Module.ApiRoot, ctrl._cp_path_, inst) - - for ctrl in multi_level_ctrls: - path_parts = ctrl._cp_path_.split('/') - path = '/'.join(path_parts[:-1]) - key = path_parts[-1] - parent_ctrl_classes = [c for c in self.ctrls - if c._cp_path_ == path] - if len(parent_ctrl_classes) != 1: - logger.error('No parent controller found for %s! ' - 'Please check your path in the ApiController ' - 'decorator!', ctrl) - else: - inst = ctrl() - setattr(parent_ctrl_classes[0], key, inst) - - @cherrypy.expose - def index(self): - tpl = """API Endpoints:
- - """ - endpoints = ['
  • {}
  • '.format(ctrl._cp_path_, ctrl.__name__) for - ctrl in self.ctrls] - return tpl.format(lis='\n'.join(endpoints)) - - class StaticRoot(object): - pass - class StandbyModule(MgrStandbyModule): def serve(self): diff --git a/src/pybind/mgr/dashboard/requirements.txt b/src/pybind/mgr/dashboard/requirements.txt index 4484ed6609a..fc623ade316 100644 --- a/src/pybind/mgr/dashboard/requirements.txt +++ b/src/pybind/mgr/dashboard/requirements.txt @@ -24,6 +24,7 @@ pytest==3.3.2 pytest-cov==2.5.1 pytz==2017.3 requests==2.18.4 +Routes==2.4.1 singledispatch==3.4.0.3 six==1.11.0 tempora==1.10 diff --git a/src/pybind/mgr/dashboard/tests/helper.py b/src/pybind/mgr/dashboard/tests/helper.py index effe21d7632..4557c528c5a 100644 --- a/src/pybind/mgr/dashboard/tests/helper.py +++ b/src/pybind/mgr/dashboard/tests/helper.py @@ -8,10 +8,23 @@ import cherrypy from cherrypy.test import helper from ..controllers.auth import Auth -from ..tools import json_error_page, SessionExpireAtBrowserCloseTool +from ..tools import json_error_page, SessionExpireAtBrowserCloseTool, \ + generate_controller_routes class ControllerTestCase(helper.CPWebCase): + @classmethod + def setup_controllers(cls, ctrl_classes, base_url=''): + if not isinstance(ctrl_classes, list): + ctrl_classes = [ctrl_classes] + mapper = cherrypy.dispatch.RoutesDispatcher() + for ctrl in ctrl_classes: + generate_controller_routes(ctrl, mapper, base_url) + if base_url == '': + base_url = '/' + cherrypy.tree.mount(None, config={ + base_url: {'request.dispatch': mapper}}) + def __init__(self, *args, **kwargs): cherrypy.tools.authenticate = cherrypy.Tool('before_handler', Auth.check_auth) cherrypy.tools.session_expire_at_browser_close = SessionExpireAtBrowserCloseTool() diff --git a/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py b/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py index c283c947d84..c4d04c22449 100644 --- a/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/tests/test_rbd_mirroring.py @@ -3,8 +3,6 @@ from __future__ import absolute_import import json import mock -import cherrypy - from .. import mgr from ..controllers.summary import Summary from ..controllers.rbd_mirroring import RbdMirror @@ -66,8 +64,7 @@ class RbdMirroringControllerTest(ControllerTestCase): Summary._cp_config['tools.authenticate.on'] = False # pylint: disable=protected-access - cherrypy.tree.mount(RbdMirror(), '/api/test/rbdmirror') - cherrypy.tree.mount(Summary(), '/api/test/summary') + cls.setup_controllers([RbdMirror, Summary], '/api/test') @mock.patch('dashboard.controllers.rbd_mirroring.rbd') def test_default(self, rbd_mock): # pylint: disable=W0613 diff --git a/src/pybind/mgr/dashboard/tests/test_tcmu_iscsi.py b/src/pybind/mgr/dashboard/tests/test_tcmu_iscsi.py index 645695a1227..f7b3ff08f85 100644 --- a/src/pybind/mgr/dashboard/tests/test_tcmu_iscsi.py +++ b/src/pybind/mgr/dashboard/tests/test_tcmu_iscsi.py @@ -1,7 +1,5 @@ from __future__ import absolute_import -import cherrypy - from .. import mgr from ..controllers.tcmu_iscsi import TcmuIscsi from .helper import ControllerTestCase @@ -73,10 +71,10 @@ class TcmuIscsiControllerTest(ControllerTestCase): mgr.url_prefix = '' TcmuIscsi._cp_config['tools.authenticate.on'] = False # pylint: disable=protected-access - cherrypy.tree.mount(TcmuIscsi(), "/api/test/tcmu") + cls.setup_controllers(TcmuIscsi, "/api/test") def test_list(self): - self._get('/api/test/tcmu') + self._get('/api/test/tcmuiscsi') self.assertStatus(200) self.assertJsonBody({ 'daemons': [ diff --git a/src/pybind/mgr/dashboard/tests/test_tools.py b/src/pybind/mgr/dashboard/tests/test_tools.py index ff79ccbeb02..7812f6a749f 100644 --- a/src/pybind/mgr/dashboard/tests/test_tools.py +++ b/src/pybind/mgr/dashboard/tests/test_tools.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -import cherrypy from cherrypy.lib.sessions import RamSession from mock import patch @@ -14,15 +13,15 @@ from ..tools import RESTController, ApiController class FooResource(RESTController): elems = [] - def list(self, *vpath, **params): + def list(self): return FooResource.elems - def create(self, data, *args, **kwargs): + def create(self, data): FooResource.elems.append(data) return data - def get(self, key, *args, **kwargs): - return {'detail': (key, args)} + def get(self, key): + return {'detail': (key, [])} def delete(self, key): del FooResource.elems[int(key)] @@ -35,10 +34,16 @@ class FooResource(RESTController): return dict(key=key, **data) +@ApiController('foo/:key/:method') +class FooResourceDetail(RESTController): + def list(self, key, method): + return {'detail': (key, [method])} + + @ApiController('fooargs') class FooArgs(RESTController): @RESTController.args_from_json - def set(self, code, name, opt1=None, opt2=None): + def set(self, code, name=None, opt1=None, opt2=None): return {'code': code, 'name': name, 'opt1': opt1, 'opt2': opt2} @@ -52,7 +57,7 @@ class RESTControllerTest(ControllerTestCase): @classmethod def setup_server(cls): - cherrypy.tree.mount(Root()) + cls.setup_controllers([FooResource, FooResourceDetail, FooArgs]) def test_empty(self): self._delete("/foo") @@ -84,11 +89,11 @@ class RESTControllerTest(ControllerTestCase): def test_not_implemented(self): self._put("/foo") - self.assertStatus(405) + self.assertStatus(404) body = self.jsonBody() self.assertIsInstance(body, dict) - assert body['detail'] == 'Method not implemented.' - assert '405' in body['status'] + assert body['detail'] == "The path '/foo' was not found." + assert '404' in body['status'] assert 'traceback' in body def test_args_from_json(self): @@ -112,7 +117,7 @@ class RESTControllerTest(ControllerTestCase): self.assertJsonBody({'detail': ['1', ['detail']]}) self._post('/foo/1/detail', 'post-data') - self.assertStatus(405) + self.assertStatus(404) def test_developer_page(self): self.getPage('/foo', headers=[('Accept', 'text/html')]) @@ -126,8 +131,4 @@ class RESTControllerTest(ControllerTestCase): self.getPage('/foo', headers=[('Accept', 'text/html'), ('Content-Length', '0')], method='put') - assert '

    PUT' in self.body.decode('utf-8') - assert 'Exception' in self.body.decode('utf-8') - assert 'Content-Type: text/html' in self.body.decode('utf-8') - assert '

    ' in self.body.decode('utf-8') - assert '' in self.body.decode('utf-8') + self.assertStatus(404) diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py index d4f175d8925..14c41197e7e 100644 --- a/src/pybind/mgr/dashboard/tools.py +++ b/src/pybind/mgr/dashboard/tools.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# pylint: disable=W0212 +# pylint: disable=W0212,too-many-lines from __future__ import absolute_import import collections @@ -19,7 +19,7 @@ import cherrypy from six import add_metaclass from .settings import Settings -from . import logger, mgr +from . import logger def ApiController(path): @@ -69,17 +69,85 @@ def load_controllers(): # 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) return controllers +def generate_controller_routes(ctrl_class, mapper, base_url): + inst = ctrl_class() + for methods, url_suffix, action, params in ctrl_class.endpoints(): + if not url_suffix: + name = ctrl_class.__name__ + url = "{}/{}".format(base_url, ctrl_class._cp_path_) + else: + name = "{}:{}".format(ctrl_class.__name__, url_suffix) + url = "{}/{}/{}".format(base_url, ctrl_class._cp_path_, url_suffix) + + if params: + for param in params: + url = "{}/:{}".format(url, param) + + conditions = dict(method=methods) if methods else None + + logger.debug("Mapping [%s] to %s:%s restricted to %s", + url, ctrl_class.__name__, action, methods) + mapper.connect(name, url, controller=inst, action=action, + conditions=conditions) + + # adding route with trailing slash + name += "/" + url += "/" + mapper.connect(name, url, controller=inst, action=action, + conditions=conditions) + + +def generate_routes(url_prefix): + mapper = cherrypy.dispatch.RoutesDispatcher() + ctrls = load_controllers() + for ctrl in ctrls: + generate_controller_routes(ctrl, mapper, "{}/api".format(url_prefix)) + + mapper.connect(ApiRoot.__name__, "{}/api".format(url_prefix), + controller=ApiRoot("{}/api".format(url_prefix), + ctrls)) + return mapper + + 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)) +class ApiRoot(object): + + _cp_config = { + 'tools.sessions.on': True, + 'tools.authenticate.on': True + } + + def __init__(self, base_url, ctrls): + self.base_url = base_url + self.ctrls = ctrls + + def __call__(self): + tpl = """API Endpoints:
    +
      + {lis} +
    + """ + endpoints = ['
  • {}
  • ' + .format(self.base_url, ctrl._cp_path_, ctrl.__name__) for + ctrl in self.ctrls] + return tpl.format(lis='\n'.join(endpoints)) + + # pylint: disable=too-many-locals def browsable_api_view(meth): def wrapper(self, *vpath, **kwargs): @@ -190,8 +258,7 @@ class BaseControllerMeta(type): def __new__(mcs, name, bases, dct): new_cls = type.__new__(mcs, name, bases, dct) - for a_name in new_cls.__dict__: - thing = new_cls.__dict__[a_name] + for a_name, thing in new_cls.__dict__.items(): if isinstance(thing, (types.FunctionType, types.MethodType))\ and getattr(thing, 'exposed', False): @@ -208,9 +275,189 @@ class BaseController(object): Base class for all controllers providing API endpoints. """ + def __init__(self): + logger.info('Initializing controller: %s -> /api/%s', + self.__class__.__name__, self._cp_path_) + + @classmethod + def _parse_function_args(cls, func): + # pylint: disable=deprecated-method + if sys.version_info > (3, 0): # pylint: disable=no-else-return + sig = inspect.signature(func) + cargs = [k for k, v in sig.parameters.items() + if k != 'self' and v.default is inspect.Parameter.empty and + (v.kind == inspect.Parameter.POSITIONAL_ONLY or + v.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD)] + else: + args = inspect.getargspec(func) + nd = len(args.args) if not args.defaults else -len(args.defaults) + cargs = args.args[1:nd] + + # filter out controller path params + for idx, step in enumerate(cls._cp_path_.split('/')): + if step[0] == ':': + param = step[1:] + if param not in cargs: + raise Exception("function '{}' does not have the" + " positional argument '{}' in the {} " + "position".format(func, param, idx)) + cargs.remove(param) + return cargs + + @classmethod + def endpoints(cls): + result = [] + + def isfunction(m): + return inspect.isfunction(m) or inspect.ismethod(m) + + for attr, val in inspect.getmembers(cls, predicate=isfunction): + if (hasattr(val, 'exposed') and val.exposed): + args = cls._parse_function_args(val) + suffix = attr + action = attr + if attr == '__call__': + suffix = None + result.append(([], suffix, action, args)) + return result + + +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) + * delete(key) + + Test with curl: + + curl -H "Content-Type: application/json" -X POST \ + -d '{"username":"xyz","password":"xyz"}' http://127.0.0.1:8080/foo + curl http://127.0.0.1:8080/foo + curl http://127.0.0.1:8080/foo/0 + + """ + + _method_mapping = { + ('GET', False): ('list', 200), + ('PUT', False): ('bulk_set', 200), + ('PATCH', False): ('bulk_set', 200), + ('POST', False): ('create', 201), + ('DELETE', False): ('bulk_delete', 204), + ('GET', True): ('get', 200), + ('PUT', True): ('set', 200), + ('PATCH', True): ('set', 200), + ('DELETE', True): ('delete', 204), + } + + @classmethod + def endpoints(cls): + + def isfunction(m): + return inspect.isfunction(m) or inspect.ismethod(m) + + result = [] + for attr, val in inspect.getmembers(cls, predicate=isfunction): + if hasattr(val, 'exposed') and val.exposed and \ + attr != '_collection' and attr != '_element': + result.append(([], attr, attr, cls._parse_function_args(val))) + + methods = [] + for k, v in cls._method_mapping.items(): + if not k[1] and hasattr(cls, v[0]): + methods.append(k[0]) + if methods: + result.append((methods, None, '_collection', [])) + methods = [] + args = [] + for k, v in cls._method_mapping.items(): + if k[1] and hasattr(cls, v[0]): + methods.append(k[0]) + if not args: + args = cls._parse_function_args(getattr(cls, v[0])) + if methods: + result.append((methods, None, '_element', args)) + + return result + @cherrypy.expose - def default(self, *_vpath, **_params): - raise cherrypy.NotFound() + def _collection(self, *vpath, **params): + return self._rest_request(False, *vpath, **params) + + @cherrypy.expose + def _element(self, *vpath, **params): + return self._rest_request(True, *vpath, **params) + + def _rest_request(self, is_element, *vpath, **params): + method_name, status_code = self._method_mapping[ + (cherrypy.request.method, is_element)] + method = getattr(self, method_name, None) + + if cherrypy.request.method not in ['GET', 'DELETE']: + method = RESTController._takes_json(method) + + if cherrypy.request.method != 'DELETE': + method = RESTController._returns_json(method) + + cherrypy.response.status = status_code + + return method(*vpath, **params) + + @staticmethod + def args_from_json(func): + func._args_from_json_ = True + return func + + @staticmethod + def _function_args(func): + if sys.version_info > (3, 0): # pylint: disable=no-else-return + return list(inspect.signature(func).parameters.keys()) + else: + return inspect.getargspec(func).args[1:] # pylint: disable=deprecated-method + + # pylint: disable=W1505 + @staticmethod + def _takes_json(func): + def inner(*args, **kwargs): + if cherrypy.request.headers.get('Content-Type', + '') == 'application/x-www-form-urlencoded': + if hasattr(func, '_args_from_json_'): # pylint: disable=no-else-return + return func(*args, **kwargs) + else: + return func(kwargs) + + content_length = int(cherrypy.request.headers['Content-Length']) + body = cherrypy.request.body.read(content_length) + if not body: + raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}' + .format(content_length)) + try: + data = json.loads(body.decode('utf-8')) + except Exception as e: + raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}' + .format(str(e))) + if hasattr(func, '_args_from_json_'): + kwargs.update(data.items()) + return func(*args, **kwargs) + + return func(data, *args, **kwargs) + return inner + + @staticmethod + def _returns_json(func): + def inner(*args, **kwargs): + cherrypy.response.headers['Content-Type'] = 'application/json' + ret = func(*args, **kwargs) + return json.dumps(ret).encode('utf8') + return inner class RequestLoggingTool(cherrypy.Tool): @@ -405,145 +652,6 @@ class ViewCache(object): return wrapper -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) - * delete(key) - - Test with curl: - - curl -H "Content-Type: application/json" -X POST \ - -d '{"username":"xyz","password":"xyz"}' http://127.0.0.1:8080/foo - curl http://127.0.0.1:8080/foo - curl http://127.0.0.1:8080/foo/0 - - """ - - def _not_implemented(self, is_sub_path): - methods = [method - for ((method, _is_element), (meth, _)) - in self._method_mapping.items() - if _is_element == is_sub_path is not None and hasattr(self, meth)] - cherrypy.response.headers['Allow'] = ','.join(methods) - raise cherrypy.HTTPError(405, 'Method not implemented.') - - _method_mapping = { - ('GET', False): ('list', 200), - ('PUT', False): ('bulk_set', 200), - ('PATCH', False): ('bulk_set', 200), - ('POST', False): ('create', 201), - ('DELETE', False): ('bulk_delete', 204), - ('GET', True): ('get', 200), - ('PUT', True): ('set', 200), - ('PATCH', True): ('set', 200), - ('DELETE', True): ('delete', 204), - } - - def _get_method(self, vpath): - is_sub_path = bool(len(vpath)) - try: - method_name, status_code = self._method_mapping[ - (cherrypy.request.method, is_sub_path)] - except KeyError: - self._not_implemented(is_sub_path) - method = getattr(self, method_name, None) - if not method: - self._not_implemented(is_sub_path) - return method, status_code - - @cherrypy.expose - def default(self, *vpath, **params): - if cherrypy.request.path_info.startswith( - '{}/api/{}/default'.format(mgr.url_prefix, self._cp_path_)) or \ - cherrypy.request.path_info.startswith('/{}/default'.format(self._cp_path_)): - # These two calls to default() are identical: `vpath` and - # params` are both empty: - # $ curl 'http://localhost/api/cp_path/' - # and - # $ curl 'http://localhost/api/cp_path/default' - # But we need to distinguish them. To fix this, we need - # to add the missing `default` - vpath = ['default'] + list(vpath) - - method, status_code = self._get_method(vpath) - - if cherrypy.request.method not in ['GET', 'DELETE']: - method = RESTController._takes_json(method) - - if cherrypy.request.method != 'DELETE': - method = RESTController._returns_json(method) - - cherrypy.response.status = status_code - - return method(*vpath, **params) - - @staticmethod - def args_from_json(func): - func._args_from_json_ = True - return func - - @staticmethod - def _function_args(func): - if sys.version_info > (3, 0): # pylint: disable=no-else-return - return list(inspect.signature(func).parameters.keys()) - else: - return inspect.getargspec(func).args[1:] # pylint: disable=deprecated-method - - # pylint: disable=W1505 - @staticmethod - def _takes_json(func): - def inner(*args, **kwargs): - if cherrypy.request.headers.get('Content-Type', - '') == 'application/x-www-form-urlencoded': - if hasattr(func, '_args_from_json_'): # pylint: disable=no-else-return - return func(*args, **kwargs) - else: - return func(kwargs) - - content_length = int(cherrypy.request.headers['Content-Length']) - body = cherrypy.request.body.read(content_length) - if not body: - raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}' - .format(content_length)) - try: - data = json.loads(body.decode('utf-8')) - except Exception as e: - raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}' - .format(str(e))) - if hasattr(func, '_args_from_json_'): - kwargs.update(data.items()) - return func(*args, **kwargs) - - return func(data, *args, **kwargs) - return inner - - @staticmethod - def _returns_json(func): - def inner(*args, **kwargs): - cherrypy.response.headers['Content-Type'] = 'application/json' - ret = func(*args, **kwargs) - return json.dumps(ret).encode('utf8') - return inner - - @staticmethod - def split_vpath(vpath): - if not vpath: - return None, None - if len(vpath) == 1: - return vpath[0], None - return vpath[0], vpath[1] - - class Session(object): """ This class contains all relevant settings related to cherrypy.session. -- 2.39.5