diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py new file mode 100644 index 00000000..8230aada --- /dev/null +++ b/mopidy/utils/jsonrpc.py @@ -0,0 +1,383 @@ +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. + + The wrapper supports exporting the methods of one or more objects. Either + way, the objects must be exported with method name prefixes, called + "mounts". + + To expose 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': foo, + 'hello': lambda: 'Hello, world!', + }) + + This will export the Python callables on the left as the JSON-RPC 2.0 + method names on the right:: + + foo.bar() -> foo.bar + foo.baz() -> foo.baz + lambda -> hello + + Only the public methods of the mounted objects, or functions/methods + included directly in the mapping, will be exposed. + + 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): + if '' in objects.keys(): + raise AttributeError( + 'The empty string is not allowed as an object mount') + self.objects = objects + self.decoder = get_combined_json_decoder(decoders or []) + self.encoder = get_combined_json_encoder(encoders or []) + + 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 + + result = self._unwrap_result(result) + + 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, method_path): + if inspect.isroutine(self.objects.get(method_path, None)): + # The mounted object is the callable + return self.objects[method_path] + + # The mounted object contains the callable + + if '.' not in method_path: + raise JsonRpcMethodNotFoundError( + data='Could not find object mount in method name "%s"' % ( + method_path)) + + mount, method_name = method_path.rsplit('.', 1) + + if method_name.startswith('_'): + raise JsonRpcMethodNotFoundError( + data='Private methods are not exported') + + try: + obj = self.objects[mount] + except KeyError: + raise JsonRpcMethodNotFoundError( + data='No object found at "%s"' % mount) + + try: + return getattr(obj, method_name) + except AttributeError: + raise JsonRpcMethodNotFoundError( + data='Object mounted at "%s" has no member "%s"' % ( + mount, method_name)) + + def _is_notification(self, request): + return 'id' not in request + + def _unwrap_result(self, result): + if isinstance(result, pykka.Future): + result = result.get() + return result + + +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 one or more 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': Foo, + 'hello': lambda: 'Hello, world!', + }) + + Since the inspector is based on inspecting classes and not instances, it + will not include methods added dynamically. The wrapper works with + instances, and it will thus export dynamically added methods as well. + + :param objects: mapping between mounts and exposed functions or classes + :type objects: dict + """ + + def __init__(self, objects): + if '' in objects.keys(): + raise AttributeError( + 'The empty string is not allowed as an object mount') + 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 diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py new file mode 100644 index 00000000..64b5e628 --- /dev/null +++ b/tests/utils/jsonrpc_test.py @@ -0,0 +1,612 @@ +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!', + 'calc': Calculator(), + 'core': self.core, + 'core.playback': self.core.playback, + 'core.tracklist': self.core.tracklist, + }, + encoders=[models.ModelJSONEncoder], + decoders=[models.model_json_decoder]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + +class JsonRpcSetupTest(JsonRpcTestBase): + def test_empty_object_mounts_is_not_allowed(self): + test = lambda: jsonrpc.JsonRpcWrapper(objects={'': Calculator()}) + self.assertRaises(AttributeError, test) + + +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(self): + request = { + 'jsonrpc': '2.0', + 'method': 'calc.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': 'calc.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_method_on_without_object_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'bogus', + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + self.assertEqual( + error['data'], + 'Could not find object mount in method name "bogus"') + + def test_method_on_unknown_object_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'bogus.bogus', + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + self.assertEqual(error['data'], 'No object found at "bogus"') + + def test_unknown_method_on_known_object_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core.bogus', + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + self.assertEqual( + error['data'], 'Object mounted at "core" has no member "bogus"') + + def test_private_method_causes_unknown_method_error(self): + request = { + 'jsonrpc': '2.0', + 'method': 'core._secret', + 'id': 1, + } + response = self.jrw.handle_data(request) + + error = response['error'] + self.assertEqual(error['code'], -32601) + self.assertEqual(error['message'], 'Method not found') + self.assertEqual(error['data'], 'Private methods are not exported') + + 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_empty_object_mounts_is_not_allowed(self): + test = lambda: jsonrpc.JsonRpcInspector(objects={'': Calculator}) + self.assertRaises(AttributeError, test) + + 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_an_object_with_methods(self): + inspector = jsonrpc.JsonRpcInspector({ + 'calc': Calculator, + }) + + methods = inspector.describe() + + self.assertIn('calc.add', methods) + self.assertEqual( + methods['calc.add']['description'], + 'Returns the sum of the given numbers') + + self.assertIn('calc.sub', methods) + self.assertIn('calc.take_it_all', methods) + self.assertNotIn('calc._secret', methods) + self.assertNotIn('calc.__init__', methods) + + method = methods['calc.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_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)