From 80b6ce804fb6a26d0d2b03f2758a544d9be683cc Mon Sep 17 00:00:00 2001 From: Sebastian Wagner Date: Mon, 26 Mar 2018 17:16:37 +0200 Subject: [PATCH] mgr/dashboard: Add minimalistic browsable API Also provides a simple HTML form to POST data to a `RESTController`'s `create()` method. Also added ENABLE_BROWSABLE_API setting to the dashboard Signed-off-by: Sebastian Wagner --- .../mgr/dashboard/controllers/monitor.py | 2 +- .../dashboard/controllers/rbd_mirroring.py | 2 +- .../mgr/dashboard/controllers/summary.py | 2 +- src/pybind/mgr/dashboard/settings.py | 2 +- .../mgr/dashboard/tests/test_notification.py | 2 +- src/pybind/mgr/dashboard/tests/test_tools.py | 21 ++- src/pybind/mgr/dashboard/tools.py | 144 ++++++++++++++++++ 7 files changed, 169 insertions(+), 6 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/monitor.py b/src/pybind/mgr/dashboard/controllers/monitor.py index ac3bfe478b6..b9733fee7fc 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): + def default(self, *_vpath, **_params): in_quorum, out_quorum = [], [] counters = ['mon.num_sessions'] diff --git a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py index 62164ffa479..2e799817cb2 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py +++ b/src/pybind/mgr/dashboard/controllers/rbd_mirroring.py @@ -161,7 +161,7 @@ class RbdMirror(BaseController): @cherrypy.expose @cherrypy.tools.json_out() - def default(self): + def default(self, *_vpath, **_params): 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 93631bbaec1..d589195f609 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): + def default(self, *_vpath, **_params): return { 'rbd_pools': self._rbd_pool_data(), 'health_status': self._health_status(), diff --git a/src/pybind/mgr/dashboard/settings.py b/src/pybind/mgr/dashboard/settings.py index 4f68fbb46ff..22159694c6d 100644 --- a/src/pybind/mgr/dashboard/settings.py +++ b/src/pybind/mgr/dashboard/settings.py @@ -18,7 +18,7 @@ class Options(object): GRAFANA_API_HOST = ('localhost', str) GRAFANA_API_PORT = (3000, int) """ - pass + ENABLE_BROWSABLE_API = (True, bool) class SettingsMeta(type): diff --git a/src/pybind/mgr/dashboard/tests/test_notification.py b/src/pybind/mgr/dashboard/tests/test_notification.py index bca27f9e6f9..151c8a59ed4 100644 --- a/src/pybind/mgr/dashboard/tests/test_notification.py +++ b/src/pybind/mgr/dashboard/tests/test_notification.py @@ -56,7 +56,7 @@ class NotificationQueueTest(unittest.TestCase): with self.assertRaises(Exception) as ctx: NotificationQueue.register(None, 1) self.assertEqual(str(ctx.exception), - "types param is neither a string nor a list") + "n_types param is neither a string nor a list") def test_notifications(self): NotificationQueue.start_queue() diff --git a/src/pybind/mgr/dashboard/tests/test_tools.py b/src/pybind/mgr/dashboard/tests/test_tools.py index 4621d5a97db..ce7eadaefd7 100644 --- a/src/pybind/mgr/dashboard/tests/test_tools.py +++ b/src/pybind/mgr/dashboard/tests/test_tools.py @@ -6,10 +6,11 @@ from cherrypy.lib.sessions import RamSession from mock import patch from .helper import ControllerTestCase -from ..tools import RESTController +from ..tools import RESTController, ApiController # pylint: disable=W0613 +@ApiController('foo') class FooResource(RESTController): elems = [] @@ -107,3 +108,21 @@ class RESTControllerTest(ControllerTestCase): self._post('/foo/1/detail', 'post-data') self.assertStatus(405) + + def test_developer_page(self): + self.getPage('/foo', headers=[('Accept', 'text/html')]) + self.assertIn('

GET', self.body.decode('utf-8')) + self.assertIn('Content-Type: text/html', self.body.decode('utf-8')) + self.assertIn('

', self.body.decode('utf-8')) + self.assertIn('', + self.body.decode('utf-8')) + + def test_developer_exception_page(self): + 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') diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py index ad81033eda1..153d21f5de5 100644 --- a/src/pybind/mgr/dashboard/tools.py +++ b/src/pybind/mgr/dashboard/tools.py @@ -12,9 +12,12 @@ import pkgutil import sys import time import threading +import types # pylint: disable=import-error import cherrypy +from six import add_metaclass +from .settings import Settings from . import logger @@ -76,11 +79,138 @@ def json_error_page(status, message, traceback, version): version=version)) +# pylint: disable=too-many-locals +def browsable_api_view(meth): + def wrapper(self, *vpath, **kwargs): + assert isinstance(self, BaseController) + if not Settings.ENABLE_BROWSABLE_API: + return meth(self, *vpath, **kwargs) + if 'text/html' not in cherrypy.request.headers.get('Accept', ''): + return meth(self, *vpath, **kwargs) + if '_method' in kwargs: + cherrypy.request.method = kwargs.pop('_method').upper() + if '_raw' in kwargs: + kwargs.pop('_raw') + return meth(self, *vpath, **kwargs) + + template = """ + +

Browsable API

+ {docstring} +

Request

+

{method} {breadcrump}

+

Response

+

Status: {status_code}

+

{reponse_headers}
+ + + +
+

Data

+
{data}
+ {create_form} +

Note

+

Please note that this API is not an official Ceph REST API to be + used by third-party applications. It's primary purpose is to serve + the requirements of the Ceph Dashboard and is subject to change at + any time. Use at your own risk.

+ """ + + create_form_template = """ +

Create Form

+
+ {fields}
+ + +
+ """ + + try: + data = meth(self, *vpath, **kwargs) + except Exception as e: # pylint: disable=broad-except + except_template = """ +

Exception: {etype}: {tostr}

+
{trace}
+ Params: {kwargs} + """ + import traceback + tb = sys.exc_info()[2] + cherrypy.response.headers['Content-Type'] = 'text/html' + data = except_template.format( + etype=e.__class__.__name__, + tostr=str(e), + trace='\n'.join(traceback.format_tb(tb)), + kwargs=kwargs + ) + + if cherrypy.response.headers['Content-Type'] == 'application/json': + data = json.dumps(json.loads(data), indent=2, sort_keys=True) + + try: + create = getattr(self, 'create') + f_args = RESTController._function_args(create) + input_fields = ['{name}:'.format(name=name) for name in + f_args] + create_form = create_form_template.format( + fields='
'.join(input_fields), + path=self._cp_path_, + vpath='/'.join(vpath) + ) + except AttributeError: + create_form = '' + + def mk_breadcrump(elems): + return '/'.join([ + '{}'.format('/'.join(elems[0:i+1]), e) + for i, e in enumerate(elems) + ]) + + cherrypy.response.headers['Content-Type'] = 'text/html' + return template.format( + docstring='
{}
'.format(self.__doc__) if self.__doc__ else '', + method=cherrypy.request.method, + path=self._cp_path_, + vpath='/'.join(vpath), + breadcrump=mk_breadcrump(['api', self._cp_path_] + list(vpath)), + status_code=cherrypy.response.status, + reponse_headers='\n'.join( + '{}: {}'.format(k, v) for k, v in cherrypy.response.headers.items()), + data=data, + create_form=create_form + ) + + wrapper.exposed = True + if hasattr(meth, '_cp_config'): + wrapper._cp_config = meth._cp_config + return wrapper + + +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] + if isinstance(thing, (types.FunctionType, types.MethodType))\ + and getattr(thing, 'exposed', False): + + # @cherrypy.tools.json_out() is incompatible with our browsable_api_view decorator. + cp_config = getattr(thing, '_cp_config', {}) + if not cp_config.get('tools.json_out.on', False): + setattr(new_cls, a_name, browsable_api_view(thing)) + return new_cls + + +@add_metaclass(BaseControllerMeta) class BaseController(object): """ Base class for all controllers providing API endpoints. """ + @cherrypy.expose + def default(self, *_vpath, **_params): + raise cherrypy.NotFound() + class RequestLoggingTool(cherrypy.Tool): def __init__(self): @@ -349,10 +479,24 @@ class RESTController(BaseController): 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: -- 2.39.5