# 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
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:
}
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(
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:
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:<br>
- <ul>
- {lis}
- </ul>
- """
- endpoints = ['<li><a href="{}">{}</a></li>'.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):
# -*- coding: utf-8 -*-
-# pylint: disable=W0212
+# pylint: disable=W0212,too-many-lines
from __future__ import absolute_import
import collections
from six import add_metaclass
from .settings import Settings
-from . import logger, mgr
+from . import logger
def ApiController(path):
# 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:<br>
+ <ul>
+ {lis}
+ </ul>
+ """
+ endpoints = ['<li><a href="{}/{}">{}</a></li>'
+ .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):
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):
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):
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.