Skip to content

Commit

Permalink
mgr/dashboard: Add minimalistic browsable API
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
sebastian-philipp committed Mar 16, 2018
1 parent 9659826 commit 39ff41c
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 14 deletions.
2 changes: 1 addition & 1 deletion src/pybind/mgr/dashboard/controllers/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
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']
Expand Down
2 changes: 1 addition & 1 deletion src/pybind/mgr/dashboard/controllers/rbd_mirroring.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def __init__(self):

@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}

Expand Down
2 changes: 1 addition & 1 deletion src/pybind/mgr/dashboard/controllers/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def _rbd_mirroring(self):

@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(),
Expand Down
2 changes: 1 addition & 1 deletion src/pybind/mgr/dashboard/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/pybind/mgr/dashboard/tests/test_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def test_invalid_register(self):
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()
Expand Down
21 changes: 20 additions & 1 deletion src/pybind/mgr/dashboard/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
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 = []

Expand Down Expand Up @@ -101,3 +102,21 @@ def test_detail_route(self):

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')
160 changes: 152 additions & 8 deletions src/pybind/mgr/dashboard/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -343,10 +473,24 @@ 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:
Expand Down Expand Up @@ -473,27 +617,27 @@ def stop(cls):
logger.debug("notification queue stopped")

@classmethod
def register(cls, func, types=None):
def register(cls, func, n_types=None):
"""Registers function to listen for notifications
If the second parameter `types` is omitted, the function in `func`
parameter will be called for any type of notifications.
Args:
func (function): python function ex: def foo(val)
types (str|list): the single type to listen, or a list of types
n_types (str|list): the single type to listen, or a list of notification types
"""
with cls._lock:
if not types:
if not n_types:
cls._listeners[cls._ALL_TYPES_].add(func)
return
if isinstance(types, str):
cls._listeners[types].add(func)
elif isinstance(types, list):
for typ in types:
if isinstance(n_types, str):
cls._listeners[n_types].add(func)
elif isinstance(n_types, list):
for typ in n_types:
cls._listeners[typ].add(func)
else:
raise Exception("types param is neither a string nor a list")
raise Exception("n_types param is neither a string nor a list")

@classmethod
def new_notification(cls, notify_type, notify_value):
Expand Down

0 comments on commit 39ff41c

Please sign in to comment.