From b038c4c2dba0ce1279e3302ae9ce13231e0e4679 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 16:23:16 +0100 Subject: [PATCH] Add generic JSON-RPC 2.0 object wrapper This can wrap multiple objects, which can be both plain objects and Pykka actors. To my knowledge, everything in the spec is supported. --- mopidy/utils/jsonrpc.py | 236 +++++++++++++++++++ tests/utils/jsonrpc_test.py | 453 ++++++++++++++++++++++++++++++++++++ 2 files changed, 689 insertions(+) create mode 100644 mopidy/utils/jsonrpc.py create mode 100644 tests/utils/jsonrpc_test.py diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py new file mode 100644 index 00000000..17074239 --- /dev/null +++ b/mopidy/utils/jsonrpc.py @@ -0,0 +1,236 @@ +from __future__ import unicode_literals + +import json +import traceback + + +class JsonRpcWrapper(object): + """ + Wraps 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 objects can either be Pykka actors or plain objects. Only their public + methods will be exposed, not any attributes. + + If a method returns an object with a ``get()`` method, it is assumed to be + a future object. Any futures is completed and their 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: dict of names mapped to objects to be exposed + """ + + def __init__(self, objects, decoders=None, encoders=None): + 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 + + 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('.') + root = path.pop(0) + this = self.objects[root] + 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 callable(getattr(result, 'get', None)) + + +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 diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py new file mode 100644 index 00000000..68950b59 --- /dev/null +++ b/tests/utils/jsonrpc_test.py @@ -0,0 +1,453 @@ +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): + return a + b + + def sub(self, a, b): + return a - b + + 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={ + 'core': self.core, + 'calculator': 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_plain_object(self): + request = { + 'jsonrpc': '2.0', + 'method': 'calculator.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_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': 'calculator._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)