Merge branch 'feature/json-rpc' into feature/http-frontend
This commit is contained in:
commit
90663021a4
@ -4,6 +4,19 @@ Changes
|
||||
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
v0.10.0 (in development)
|
||||
========================
|
||||
|
||||
**Changes**
|
||||
|
||||
- None yet
|
||||
|
||||
**Bug fixes**
|
||||
|
||||
- :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from
|
||||
``glib``. The bug can be worked around by overriding the settings that
|
||||
includes offending `$XDG_` variables.
|
||||
|
||||
|
||||
v0.9.0 (2012-11-21)
|
||||
===================
|
||||
|
||||
393
mopidy/utils/jsonrpc.py
Normal file
393
mopidy/utils/jsonrpc.py
Normal file
@ -0,0 +1,393 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import traceback
|
||||
|
||||
import pykka
|
||||
|
||||
|
||||
class JsonRpcWrapper(object):
|
||||
"""
|
||||
Wrap objects and make them accessible through JSON-RPC 2.0 messaging.
|
||||
|
||||
This class takes responsibility of communicating with the objects and
|
||||
processing of JSON-RPC 2.0 messages. The transport of the messages over
|
||||
HTTP, WebSocket, TCP, or whatever is of no concern to this class.
|
||||
|
||||
To expose a single object, add it to the objects mapping using the empty
|
||||
string as the key::
|
||||
|
||||
jrw = JsonRpcWrapper(objects={'': my_object})
|
||||
|
||||
To expose multiple objects, add them all to the objects mapping. The key in
|
||||
the mapping is used as the object's mounting point in the exposed API::
|
||||
|
||||
jrw = JsonRpcWrapper(objects={
|
||||
'': foo,
|
||||
'hello': lambda: 'Hello, world!',
|
||||
'abc': abc,
|
||||
})
|
||||
|
||||
This will create the following mapping between JSON-RPC 2.0 method names
|
||||
and Python callables::
|
||||
|
||||
bar -> foo.bar()
|
||||
baz -> foo.baz()
|
||||
hello -> lambda
|
||||
abc.def -> abc.def()
|
||||
|
||||
Only the public methods of the objects will be exposed. Attributes are not
|
||||
exposed by themself, but public methods on public attributes are exposed,
|
||||
using dotted paths from the exposed object to the method at the end of the
|
||||
path.
|
||||
|
||||
If a method returns a :class:`pykka.Future`, the future will be completed
|
||||
and its value unwrapped before the JSON-RPC wrapper returns the response.
|
||||
|
||||
For further details on the JSON-RPC 2.0 spec, see
|
||||
http://www.jsonrpc.org/specification
|
||||
|
||||
:param objects: mapping between mounting points and exposed functions or
|
||||
class instances
|
||||
:type objects: dict
|
||||
:param decoders: object builders to be used by :func`json.loads`
|
||||
:type decoders: list of functions taking a dict and returning a dict
|
||||
:param encoders: object serializers to be used by :func:`json.dumps`
|
||||
:type encoders: list of :class:`json.JSONEncoder` subclasses with the
|
||||
method :meth:`default` implemented
|
||||
"""
|
||||
|
||||
def __init__(self, objects, decoders=None, encoders=None):
|
||||
self.obj = self._build_exported_object(objects)
|
||||
self.decoder = get_combined_json_decoder(decoders or [])
|
||||
self.encoder = get_combined_json_encoder(encoders or [])
|
||||
|
||||
def _build_exported_object(self, objects):
|
||||
class EmptyObject(object):
|
||||
pass
|
||||
|
||||
if '' in objects:
|
||||
exported_object = objects['']
|
||||
else:
|
||||
exported_object = EmptyObject()
|
||||
|
||||
mounts = sorted(objects.keys(), key=lambda x: len(x))
|
||||
for mount in mounts:
|
||||
parent = exported_object
|
||||
path = mount.split('.')
|
||||
for part in path[:-1]:
|
||||
if not hasattr(parent, part):
|
||||
setattr(parent, part, EmptyObject())
|
||||
parent = getattr(parent, part)
|
||||
if path[-1]:
|
||||
setattr(parent, path[-1], objects[mount])
|
||||
|
||||
return exported_object
|
||||
|
||||
def handle_json(self, request):
|
||||
"""
|
||||
Handles an incoming request encoded as a JSON string.
|
||||
|
||||
Returns a response as a JSON string for commands, and :class:`None` for
|
||||
notifications.
|
||||
|
||||
:param request: the serialized JSON-RPC request
|
||||
:type request: string
|
||||
:rtype: string or :class:`None`
|
||||
"""
|
||||
try:
|
||||
request = json.loads(request, object_hook=self.decoder)
|
||||
except ValueError:
|
||||
response = JsonRpcParseError().get_response()
|
||||
else:
|
||||
response = self.handle_data(request)
|
||||
if response is None:
|
||||
return None
|
||||
return json.dumps(response, cls=self.encoder)
|
||||
|
||||
def handle_data(self, request):
|
||||
"""
|
||||
Handles an incoming request in the form of a Python data structure.
|
||||
|
||||
Returns a Python data structure for commands, or a :class:`None` for
|
||||
notifications.
|
||||
|
||||
:param request: the unserialized JSON-RPC request
|
||||
:type request: dict
|
||||
:rtype: dict, list, or :class:`None`
|
||||
"""
|
||||
if isinstance(request, list):
|
||||
return self._handle_batch(request)
|
||||
else:
|
||||
return self._handle_single_request(request)
|
||||
|
||||
def _handle_batch(self, requests):
|
||||
if not requests:
|
||||
return JsonRpcInvalidRequestError(
|
||||
data='Batch list cannot be empty').get_response()
|
||||
|
||||
responses = []
|
||||
for request in requests:
|
||||
response = self._handle_single_request(request)
|
||||
if response:
|
||||
responses.append(response)
|
||||
|
||||
return responses or None
|
||||
|
||||
def _handle_single_request(self, request):
|
||||
try:
|
||||
self._validate_request(request)
|
||||
args, kwargs = self._get_params(request)
|
||||
except JsonRpcInvalidRequestError as error:
|
||||
return error.get_response()
|
||||
|
||||
try:
|
||||
method = self._get_method(request['method'])
|
||||
|
||||
try:
|
||||
result = method(*args, **kwargs)
|
||||
|
||||
if self._is_notification(request):
|
||||
return None
|
||||
|
||||
if self._is_future(result):
|
||||
result = result.get()
|
||||
|
||||
return {
|
||||
'jsonrpc': '2.0',
|
||||
'id': request['id'],
|
||||
'result': result,
|
||||
}
|
||||
except TypeError as error:
|
||||
raise JsonRpcInvalidParamsError(data={
|
||||
'type': error.__class__.__name__,
|
||||
'message': unicode(error),
|
||||
'traceback': traceback.format_exc(),
|
||||
})
|
||||
except Exception as error:
|
||||
raise JsonRpcApplicationError(data={
|
||||
'type': error.__class__.__name__,
|
||||
'message': unicode(error),
|
||||
'traceback': traceback.format_exc(),
|
||||
})
|
||||
except JsonRpcError as error:
|
||||
if self._is_notification(request):
|
||||
return None
|
||||
return error.get_response(request['id'])
|
||||
|
||||
def _validate_request(self, request):
|
||||
if not isinstance(request, dict):
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='Request must be an object')
|
||||
if not 'jsonrpc' in request:
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"jsonrpc" member must be included')
|
||||
if request['jsonrpc'] != '2.0':
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"jsonrpc" value must be "2.0"')
|
||||
if not 'method' in request:
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"method" member must be included')
|
||||
if not isinstance(request['method'], unicode):
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"method" must be a string')
|
||||
|
||||
def _get_params(self, request):
|
||||
if not 'params' in request:
|
||||
return [], {}
|
||||
params = request['params']
|
||||
if isinstance(params, list):
|
||||
return params, {}
|
||||
elif isinstance(params, dict):
|
||||
return [], params
|
||||
else:
|
||||
raise JsonRpcInvalidRequestError(
|
||||
data='"params", if given, must be an array or an object')
|
||||
|
||||
def _get_method(self, name):
|
||||
try:
|
||||
path = name.split('.')
|
||||
this = self.obj
|
||||
for part in path:
|
||||
if part.startswith('_'):
|
||||
raise AttributeError
|
||||
this = getattr(this, part)
|
||||
return this
|
||||
except (AttributeError, KeyError):
|
||||
raise JsonRpcMethodNotFoundError()
|
||||
|
||||
def _is_notification(self, request):
|
||||
return 'id' not in request
|
||||
|
||||
def _is_future(self, result):
|
||||
return isinstance(result, pykka.Future)
|
||||
|
||||
|
||||
class JsonRpcError(Exception):
|
||||
code = -32000
|
||||
message = 'Unspecified server error'
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = data
|
||||
|
||||
def get_response(self, request_id=None):
|
||||
response = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': request_id,
|
||||
'error': {
|
||||
'code': self.code,
|
||||
'message': self.message,
|
||||
},
|
||||
}
|
||||
if self.data:
|
||||
response['error']['data'] = self.data
|
||||
return response
|
||||
|
||||
|
||||
class JsonRpcParseError(JsonRpcError):
|
||||
code = -32700
|
||||
message = 'Parse error'
|
||||
|
||||
|
||||
class JsonRpcInvalidRequestError(JsonRpcError):
|
||||
code = -32600
|
||||
message = 'Invalid Request'
|
||||
|
||||
|
||||
class JsonRpcMethodNotFoundError(JsonRpcError):
|
||||
code = -32601
|
||||
message = 'Method not found'
|
||||
|
||||
|
||||
class JsonRpcInvalidParamsError(JsonRpcError):
|
||||
code = -32602
|
||||
message = 'Invalid params'
|
||||
|
||||
|
||||
class JsonRpcApplicationError(JsonRpcError):
|
||||
code = 0
|
||||
message = 'Application error'
|
||||
|
||||
|
||||
def get_combined_json_decoder(decoders):
|
||||
def decode(dct):
|
||||
for decoder in decoders:
|
||||
dct = decoder(dct)
|
||||
return dct
|
||||
return decode
|
||||
|
||||
|
||||
def get_combined_json_encoder(encoders):
|
||||
class JsonRpcEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
for encoder in encoders:
|
||||
try:
|
||||
return encoder().default(obj)
|
||||
except TypeError:
|
||||
pass # Try next encoder
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
return JsonRpcEncoder
|
||||
|
||||
|
||||
class JsonRpcInspector(object):
|
||||
"""
|
||||
Inspects a group of classes and functions to create a description of what
|
||||
methods they can expose over JSON-RPC 2.0.
|
||||
|
||||
To inspect a single class, add it to the objects mapping using the empty
|
||||
string as the key::
|
||||
|
||||
jri = JsonRpcInspector(objects={'': MyClass})
|
||||
|
||||
To inspect multiple classes, add them all to the objects mapping. The key
|
||||
in the mapping is used as the classes' mounting point in the exposed API::
|
||||
|
||||
jri = JsonRpcInspector(objects={
|
||||
'': Foo,
|
||||
'hello': lambda: 'Hello, world!',
|
||||
'abc': Abc,
|
||||
})
|
||||
|
||||
Since this inspector is based on inspecting classes and not instances, it
|
||||
will not give you a complete picture of what is actually exported by
|
||||
:class:`JsonRpcWrapper`. In particular:
|
||||
|
||||
- it will not include methods added dynamically, and
|
||||
- it will not include public methods on attributes on the instances that
|
||||
are to be exposed.
|
||||
|
||||
:param objects: mapping between mounts and exposed functions or classes
|
||||
:type objects: dict
|
||||
"""
|
||||
|
||||
def __init__(self, objects):
|
||||
self.objects = objects
|
||||
|
||||
def describe(self):
|
||||
"""
|
||||
Inspects the object and returns a data structure which describes the
|
||||
available properties and methods.
|
||||
"""
|
||||
methods = {}
|
||||
for mount, obj in self.objects.iteritems():
|
||||
if inspect.isroutine(obj):
|
||||
methods[mount] = self._describe_method(obj)
|
||||
else:
|
||||
obj_methods = self._get_methods(obj)
|
||||
for name, description in obj_methods.iteritems():
|
||||
if mount:
|
||||
name = '%s.%s' % (mount, name)
|
||||
methods[name] = description
|
||||
return methods
|
||||
|
||||
def _get_methods(self, obj):
|
||||
methods = {}
|
||||
for name, value in inspect.getmembers(obj):
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
if not inspect.isroutine(value):
|
||||
continue
|
||||
method = self._describe_method(value)
|
||||
if method:
|
||||
methods[name] = method
|
||||
return methods
|
||||
|
||||
def _describe_method(self, method):
|
||||
return {
|
||||
'description': inspect.getdoc(method),
|
||||
'params': self._describe_params(method),
|
||||
}
|
||||
|
||||
def _describe_params(self, method):
|
||||
argspec = inspect.getargspec(method)
|
||||
|
||||
defaults = argspec.defaults and list(argspec.defaults) or []
|
||||
num_args_without_default = len(argspec.args) - len(defaults)
|
||||
no_defaults = [None] * num_args_without_default
|
||||
defaults = no_defaults + defaults
|
||||
|
||||
params = []
|
||||
|
||||
for arg, default in zip(argspec.args, defaults):
|
||||
if arg == 'self':
|
||||
continue
|
||||
params.append({'name': arg})
|
||||
|
||||
if argspec.defaults:
|
||||
for i, default in enumerate(reversed(argspec.defaults)):
|
||||
params[len(params) - i - 1]['default'] = default
|
||||
|
||||
if argspec.varargs:
|
||||
params.append({
|
||||
'name': argspec.varargs,
|
||||
'varargs': True,
|
||||
})
|
||||
|
||||
if argspec.keywords:
|
||||
params.append({
|
||||
'name': argspec.keywords,
|
||||
'kwargs': True,
|
||||
})
|
||||
|
||||
return params
|
||||
@ -13,14 +13,21 @@ import glib
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.path')
|
||||
|
||||
DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy')
|
||||
SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy')
|
||||
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py')
|
||||
XDG_CACHE_DIR = glib.get_user_cache_dir().decode('utf-8')
|
||||
XDG_CONFIG_DIR = glib.get_user_config_dir().decode('utf-8')
|
||||
XDG_DATA_DIR = glib.get_user_data_dir().decode('utf-8')
|
||||
XDG_MUSIC_DIR = glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)
|
||||
if XDG_MUSIC_DIR:
|
||||
XDG_MUSIC_DIR = XDG_MUSIC_DIR.decode('utf-8')
|
||||
XDG_DIRS = {
|
||||
'XDG_CACHE_DIR': glib.get_user_cache_dir(),
|
||||
'XDG_DATA_DIR': glib.get_user_data_dir(),
|
||||
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
|
||||
'XDG_CACHE_DIR': XDG_CACHE_DIR,
|
||||
'XDG_CONFIG_DIR': XDG_CONFIG_DIR,
|
||||
'XDG_DATA_DIR': XDG_DATA_DIR,
|
||||
'XDG_MUSIC_DIR': XDG_MUSIC_DIR,
|
||||
}
|
||||
DATA_PATH = os.path.join(XDG_DATA_DIR, 'mopidy')
|
||||
SETTINGS_PATH = os.path.join(XDG_CONFIG_DIR, 'mopidy')
|
||||
SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py')
|
||||
|
||||
|
||||
def get_or_create_folder(folder):
|
||||
|
||||
580
tests/utils/jsonrpc_test.py
Normal file
580
tests/utils/jsonrpc_test.py
Normal file
@ -0,0 +1,580 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
|
||||
import pykka
|
||||
import mock
|
||||
|
||||
from mopidy import core, models
|
||||
from mopidy.backends import dummy
|
||||
from mopidy.utils import jsonrpc
|
||||
|
||||
from tests import unittest
|
||||
|
||||
|
||||
class Calculator(object):
|
||||
def model(self):
|
||||
return 'TI83'
|
||||
|
||||
def add(self, a, b):
|
||||
"""Returns the sum of the given numbers"""
|
||||
return a + b
|
||||
|
||||
def sub(self, a, b):
|
||||
return a - b
|
||||
|
||||
def describe(self):
|
||||
return {
|
||||
'add': 'Returns the sum of the terms',
|
||||
'sub': 'Returns the diff of the terms',
|
||||
}
|
||||
|
||||
def take_it_all(self, a, b, c=True, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def _secret(self):
|
||||
return 'Grand Unified Theory'
|
||||
|
||||
|
||||
class JsonRpcTestBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.backend = dummy.DummyBackend.start(audio=None).proxy()
|
||||
self.core = core.Core.start(backends=[self.backend]).proxy()
|
||||
|
||||
self.jrw = jsonrpc.JsonRpcWrapper(
|
||||
objects={
|
||||
'hello': lambda: 'Hello, world!',
|
||||
'core': self.core,
|
||||
'': Calculator(),
|
||||
},
|
||||
encoders=[models.ModelJSONEncoder],
|
||||
decoders=[models.model_json_decoder])
|
||||
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
|
||||
class JsonRpcSerializationTest(JsonRpcTestBase):
|
||||
def test_handle_json_converts_from_and_to_json(self):
|
||||
self.jrw.handle_data = mock.Mock()
|
||||
self.jrw.handle_data.return_value = {'foo': 'response'}
|
||||
|
||||
request = '{"foo": "request"}'
|
||||
response = self.jrw.handle_json(request)
|
||||
|
||||
self.jrw.handle_data.assert_called_once_with({'foo': 'request'})
|
||||
self.assertEqual(response, '{"foo": "response"}')
|
||||
|
||||
def test_handle_json_decodes_mopidy_models(self):
|
||||
self.jrw.handle_data = mock.Mock()
|
||||
self.jrw.handle_data.return_value = []
|
||||
|
||||
request = '{"foo": {"__model__": "Artist", "name": "bar"}}'
|
||||
self.jrw.handle_json(request)
|
||||
|
||||
self.jrw.handle_data.assert_called_once_with(
|
||||
{'foo': models.Artist(name='bar')})
|
||||
|
||||
def test_handle_json_encodes_mopidy_models(self):
|
||||
self.jrw.handle_data = mock.Mock()
|
||||
self.jrw.handle_data.return_value = {'foo': models.Artist(name='bar')}
|
||||
|
||||
request = '[]'
|
||||
response = self.jrw.handle_json(request)
|
||||
|
||||
self.assertEqual(
|
||||
response, '{"foo": {"__model__": "Artist", "name": "bar"}}')
|
||||
|
||||
def test_handle_json_returns_nothing_for_notices(self):
|
||||
request = '{"jsonrpc": "2.0", "method": "core.get_uri_schemes"}'
|
||||
response = self.jrw.handle_json(request)
|
||||
|
||||
self.assertEqual(response, None)
|
||||
|
||||
def test_invalid_json_command_causes_parse_error(self):
|
||||
request = (
|
||||
'{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]')
|
||||
response = self.jrw.handle_json(request)
|
||||
response = json.loads(response)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32700)
|
||||
self.assertEqual(error['message'], 'Parse error')
|
||||
|
||||
def test_invalid_json_batch_causes_parse_error(self):
|
||||
request = """[
|
||||
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
|
||||
{"jsonrpc": "2.0", "method"
|
||||
]"""
|
||||
response = self.jrw.handle_json(request)
|
||||
response = json.loads(response)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32700)
|
||||
self.assertEqual(error['message'], 'Parse error')
|
||||
|
||||
|
||||
class JsonRpcSingleCommandTest(JsonRpcTestBase):
|
||||
def test_call_method_on_root(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'hello',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
self.assertEqual(response['id'], 1)
|
||||
self.assertNotIn('error', response)
|
||||
self.assertEqual(response['result'], 'Hello, world!')
|
||||
|
||||
def test_call_method_on_plain_object_as_root(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'model',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
self.assertEqual(response['id'], 1)
|
||||
self.assertNotIn('error', response)
|
||||
self.assertEqual(response['result'], 'TI83')
|
||||
|
||||
def test_call_method_which_returns_dict_from_plain_object(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'describe',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
self.assertEqual(response['id'], 1)
|
||||
self.assertNotIn('error', response)
|
||||
self.assertIn('add', response['result'])
|
||||
self.assertIn('sub', response['result'])
|
||||
|
||||
def test_call_method_on_actor_root(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.get_uri_schemes',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['jsonrpc'], '2.0')
|
||||
self.assertEqual(response['id'], 1)
|
||||
self.assertNotIn('error', response)
|
||||
self.assertEqual(response['result'], ['dummy'])
|
||||
|
||||
def test_call_method_on_actor_member(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.playback.get_volume',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['result'], None)
|
||||
|
||||
def test_call_method_with_positional_params(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.playback.set_volume',
|
||||
'params': [37],
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['result'], None)
|
||||
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||
|
||||
def test_call_methods_with_named_params(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.playback.set_volume',
|
||||
'params': {'volume': 37},
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(response['result'], None)
|
||||
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||
|
||||
|
||||
class JsonRpcSingleNotificationTest(JsonRpcTestBase):
|
||||
def test_notification_does_not_return_a_result(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.get_uri_schemes',
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response)
|
||||
|
||||
def test_notification_makes_an_observable_change(self):
|
||||
self.assertEqual(self.core.playback.get_volume().get(), None)
|
||||
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.playback.set_volume',
|
||||
'params': [37],
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response)
|
||||
self.assertEqual(self.core.playback.get_volume().get(), 37)
|
||||
|
||||
def test_notification_unknown_method_returns_nothing(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'bogus',
|
||||
'params': ['bogus'],
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response)
|
||||
|
||||
|
||||
class JsonRpcBatchTest(JsonRpcTestBase):
|
||||
def test_batch_of_only_commands_returns_all(self):
|
||||
self.core.playback.set_random(True).get()
|
||||
|
||||
request = [
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat', 'id': 1},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3},
|
||||
]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(len(response), 3)
|
||||
|
||||
response = {row['id']: row for row in response}
|
||||
self.assertEqual(response[1]['result'], False)
|
||||
self.assertEqual(response[2]['result'], True)
|
||||
self.assertEqual(response[3]['result'], False)
|
||||
|
||||
def test_batch_of_commands_and_notifications_returns_some(self):
|
||||
self.core.playback.set_random(True).get()
|
||||
|
||||
request = [
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_random', 'id': 2},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_single', 'id': 3},
|
||||
]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(len(response), 2)
|
||||
|
||||
response = {row['id']: row for row in response}
|
||||
self.assertNotIn(1, response)
|
||||
self.assertEqual(response[2]['result'], True)
|
||||
self.assertEqual(response[3]['result'], False)
|
||||
|
||||
def test_batch_of_only_notifications_returns_nothing(self):
|
||||
self.core.playback.set_random(True).get()
|
||||
|
||||
request = [
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_repeat'},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_random'},
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_single'},
|
||||
]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response)
|
||||
|
||||
|
||||
class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
|
||||
def test_application_error_response(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.tracklist.index',
|
||||
'params': ['bogus'],
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertNotIn('result', response)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], 0)
|
||||
self.assertEqual(error['message'], 'Application error')
|
||||
|
||||
data = error['data']
|
||||
self.assertEqual(data['type'], 'ValueError')
|
||||
self.assertEqual(data['message'], "u'bogus' is not in list")
|
||||
self.assertIn('traceback', data)
|
||||
self.assertIn('Traceback (most recent call last):', data['traceback'])
|
||||
|
||||
def test_missing_jsonrpc_member_causes_invalid_request_error(self):
|
||||
request = {
|
||||
'method': 'core.get_uri_schemes',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], '"jsonrpc" member must be included')
|
||||
|
||||
def test_wrong_jsonrpc_version_causes_invalid_request_error(self):
|
||||
request = {
|
||||
'jsonrpc': '3.0',
|
||||
'method': 'core.get_uri_schemes',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], '"jsonrpc" value must be "2.0"')
|
||||
|
||||
def test_missing_method_member_causes_invalid_request_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], '"method" member must be included')
|
||||
|
||||
def test_invalid_method_value_causes_invalid_request_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 1,
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], '"method" must be a string')
|
||||
|
||||
def test_invalid_params_value_causes_invalid_request_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.get_uri_schemes',
|
||||
'params': 'foobar',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(
|
||||
error['data'], '"params", if given, must be an array or an object')
|
||||
|
||||
def test_unknown_method_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'bogus',
|
||||
'params': ['bogus'],
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
|
||||
def test_private_method_causes_unknown_method_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': '_secret',
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32601)
|
||||
self.assertEqual(error['message'], 'Method not found')
|
||||
|
||||
def test_invalid_params_causes_invalid_params_error(self):
|
||||
request = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'core.get_uri_schemes',
|
||||
'params': ['bogus'],
|
||||
'id': 1,
|
||||
}
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32602)
|
||||
self.assertEqual(error['message'], 'Invalid params')
|
||||
|
||||
data = error['data']
|
||||
self.assertEqual(data['type'], 'TypeError')
|
||||
self.assertEqual(
|
||||
data['message'],
|
||||
'get_uri_schemes() takes exactly 1 argument (2 given)')
|
||||
self.assertIn('traceback', data)
|
||||
self.assertIn('Traceback (most recent call last):', data['traceback'])
|
||||
|
||||
|
||||
class JsonRpcBatchErrorTest(JsonRpcTestBase):
|
||||
def test_empty_batch_list_causes_invalid_request_error(self):
|
||||
request = []
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], 'Batch list cannot be empty')
|
||||
|
||||
def test_batch_with_invalid_command_causes_invalid_request_error(self):
|
||||
request = [1]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(len(response), 1)
|
||||
response = response[0]
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], 'Request must be an object')
|
||||
|
||||
def test_batch_with_invalid_commands_causes_invalid_request_error(self):
|
||||
request = [1, 2, 3]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(len(response), 3)
|
||||
response = response[2]
|
||||
self.assertIsNone(response['id'])
|
||||
error = response['error']
|
||||
self.assertEqual(error['code'], -32600)
|
||||
self.assertEqual(error['message'], 'Invalid Request')
|
||||
self.assertEqual(error['data'], 'Request must be an object')
|
||||
|
||||
def test_batch_of_both_successfull_and_failing_requests(self):
|
||||
request = [
|
||||
# Call with positional params
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.set_volume',
|
||||
'params': [47], 'id': '1'},
|
||||
# Notification
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.set_consume',
|
||||
'params': [True]},
|
||||
# Call with positional params
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.set_repeat',
|
||||
'params': [False], 'id': '2'},
|
||||
# Invalid request
|
||||
{'foo': 'boo'},
|
||||
# Unknown method
|
||||
{'jsonrpc': '2.0', 'method': 'foo.get',
|
||||
'params': {'name': 'myself'}, 'id': '5'},
|
||||
# Call without params
|
||||
{'jsonrpc': '2.0', 'method': 'core.playback.get_random',
|
||||
'id': '9'},
|
||||
]
|
||||
response = self.jrw.handle_data(request)
|
||||
|
||||
self.assertEqual(len(response), 5)
|
||||
response = {row['id']: row for row in response}
|
||||
self.assertEqual(response['1']['result'], None)
|
||||
self.assertEqual(response['2']['result'], None)
|
||||
self.assertEqual(response[None]['error']['code'], -32600)
|
||||
self.assertEqual(response['5']['error']['code'], -32601)
|
||||
self.assertEqual(response['9']['result'], False)
|
||||
|
||||
|
||||
class JsonRpcInspectorTest(JsonRpcTestBase):
|
||||
def test_can_describe_method_on_root(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'hello': lambda: 'Hello, world!',
|
||||
})
|
||||
|
||||
methods = inspector.describe()
|
||||
|
||||
self.assertIn('hello', methods)
|
||||
self.assertEqual(len(methods['hello']['params']), 0)
|
||||
|
||||
def test_inspector_can_describe_methods_on_the_root_class(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'': Calculator,
|
||||
})
|
||||
|
||||
methods = inspector.describe()
|
||||
|
||||
self.assertIn('add', methods)
|
||||
self.assertEqual(
|
||||
methods['add']['description'],
|
||||
'Returns the sum of the given numbers')
|
||||
|
||||
self.assertIn('sub', methods)
|
||||
self.assertIn('take_it_all', methods)
|
||||
self.assertNotIn('_secret', methods)
|
||||
self.assertNotIn('__init__', methods)
|
||||
|
||||
method = methods['take_it_all']
|
||||
self.assertIn('params', method)
|
||||
|
||||
params = method['params']
|
||||
|
||||
self.assertEqual(params[0]['name'], 'a')
|
||||
self.assertNotIn('default', params[0])
|
||||
|
||||
self.assertEqual(params[1]['name'], 'b')
|
||||
self.assertNotIn('default', params[1])
|
||||
|
||||
self.assertEqual(params[2]['name'], 'c')
|
||||
self.assertEqual(params[2]['default'], True)
|
||||
|
||||
self.assertEqual(params[3]['name'], 'args')
|
||||
self.assertNotIn('default', params[3])
|
||||
self.assertEqual(params[3]['varargs'], True)
|
||||
|
||||
self.assertEqual(params[4]['name'], 'kwargs')
|
||||
self.assertNotIn('default', params[4])
|
||||
self.assertEqual(params[4]['kwargs'], True)
|
||||
|
||||
def test_inspector_can_describe_methods_not_on_the_root(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'calc': Calculator,
|
||||
})
|
||||
|
||||
methods = inspector.describe()
|
||||
|
||||
self.assertIn('calc.add', methods)
|
||||
self.assertIn('calc.sub', methods)
|
||||
|
||||
def test_inspector_can_describe_a_bunch_of_large_classes(self):
|
||||
inspector = jsonrpc.JsonRpcInspector({
|
||||
'core.library': core.LibraryController,
|
||||
'core.playback': core.PlaybackController,
|
||||
'core.playlists': core.PlaylistsController,
|
||||
'core.tracklist': core.TracklistController,
|
||||
})
|
||||
|
||||
methods = inspector.describe()
|
||||
|
||||
self.assertIn('core.library.lookup', methods.keys())
|
||||
self.assertEquals(
|
||||
methods['core.library.lookup']['params'][0]['name'], 'uri')
|
||||
|
||||
self.assertIn('core.playback.next', methods)
|
||||
self.assertEquals(len(methods['core.playback.next']['params']), 0)
|
||||
|
||||
self.assertIn('core.playlists.get_playlists', methods)
|
||||
self.assertEquals(
|
||||
len(methods['core.playlists.get_playlists']['params']), 0)
|
||||
|
||||
self.assertIn('core.tracklist.filter', methods.keys())
|
||||
self.assertEquals(
|
||||
methods['core.tracklist.filter']['params'][0]['name'], 'criteria')
|
||||
self.assertEquals(
|
||||
methods['core.tracklist.filter']['params'][0]['kwargs'], True)
|
||||
Loading…
Reference in New Issue
Block a user