]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add minimalistic browsable API
authorSebastian Wagner <sebastian.wagner@suse.com>
Mon, 26 Mar 2018 15:16:37 +0000 (17:16 +0200)
committerSebastian Wagner <sebastian.wagner@suse.com>
Mon, 26 Mar 2018 15:16:37 +0000 (17:16 +0200)
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 <sebastian.wagner@suse.com>
src/pybind/mgr/dashboard/controllers/monitor.py
src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
src/pybind/mgr/dashboard/controllers/summary.py
src/pybind/mgr/dashboard/settings.py
src/pybind/mgr/dashboard/tests/test_notification.py
src/pybind/mgr/dashboard/tests/test_tools.py
src/pybind/mgr/dashboard/tools.py

index ac3bfe478b63eea97de5ec74c3bb90cde833d774..b9733fee7fc05e5f0c963e44451699b564190cfd 100644 (file)
@@ -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']
index 62164ffa479989406f74b665531f61d78f5f415e..2e799817cb2c5af66a0f7c917fca3cb2b7bf695d 100644 (file)
@@ -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}
 
index 93631bbaec1df6128e73fb2f0214395143b7b747..d589195f6097041614131cbf75c16f6ccae8dda0 100644 (file)
@@ -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(),
index 4f68fbb46ffd3993634ebaf9318204200bddd92f..22159694c6dc92b19043063634466df095c832e4 100644 (file)
@@ -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):
index bca27f9e6f911da4e461a301a0fb890e61e5171a..151c8a59ed4dead7ded0021042e4539fdfad1088 100644 (file)
@@ -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()
index 4621d5a97db12e7186e672269acb6de2cc488c62..ce7eadaefd788790a887f995c72d5faf9f6df2f2 100644 (file)
@@ -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('<p>GET', self.body.decode('utf-8'))
+        self.assertIn('Content-Type: text/html', self.body.decode('utf-8'))
+        self.assertIn('<form action="/api/foo/" method="post">', self.body.decode('utf-8'))
+        self.assertIn('<input type="hidden" name="_method" value="post" />',
+                      self.body.decode('utf-8'))
+
+    def test_developer_exception_page(self):
+        self.getPage('/foo',
+                     headers=[('Accept', 'text/html'), ('Content-Length', '0')],
+                     method='put')
+        assert '<p>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 '<form action="/api/foo/" method="post">' in self.body.decode('utf-8')
+        assert '<input type="hidden" name="_method" value="post" />' in self.body.decode('utf-8')
index ad81033eda18b731c7e08fc5b9c2609d62dbda69..153d21f5de5c68b854c91bf3eb68c3086c48317a 100644 (file)
@@ -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 = """
+        <html>
+        <h1>Browsable API</h1>
+        {docstring}
+        <h2>Request</h2>
+        <p>{method} {breadcrump}</p>
+        <h2>Response</h2>
+        <p>Status: {status_code}<p>
+        <pre>{reponse_headers}</pre>
+        <form action="/api/{path}/{vpath}" method="get">
+        <input type="hidden" name="_raw" value="true" />
+        <button type="submit">GET raw data</button>
+        </form>
+        <h2>Data</h2>
+        <pre>{data}</pre>
+        {create_form}
+        <h2>Note</h2>
+        <p>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.</p>
+        """
+
+        create_form_template = """
+        <h2>Create Form</h2>
+        <form action="/api/{path}/{vpath}" method="post">
+        {fields}<br>
+        <input type="hidden" name="_method" value="post" />
+        <button type="submit">Create</button>
+        </form>
+        """
+
+        try:
+            data = meth(self, *vpath, **kwargs)
+        except Exception as e:  # pylint: disable=broad-except
+            except_template = """
+            <h2>Exception: {etype}: {tostr}</h2>
+            <pre>{trace}</pre>
+            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}:<input type="text" name="{name}">'.format(name=name) for name in
+                            f_args]
+            create_form = create_form_template.format(
+                fields='<br>'.join(input_fields),
+                path=self._cp_path_,
+                vpath='/'.join(vpath)
+            )
+        except AttributeError:
+            create_form = ''
+
+        def mk_breadcrump(elems):
+            return '/'.join([
+                '<a href="/{}">{}</a>'.format('/'.join(elems[0:i+1]), e)
+                for i, e in enumerate(elems)
+            ])
+
+        cherrypy.response.headers['Content-Type'] = 'text/html'
+        return template.format(
+            docstring='<pre>{}</pre>'.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: