From b038c4c2dba0ce1279e3302ae9ce13231e0e4679 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 21 Nov 2012 16:23:16 +0100 Subject: [PATCH 01/15] 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) From 6e2ffb08206f4cab148b10a5c0c9f5a1f29aade8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 22 Nov 2012 12:11:22 +0100 Subject: [PATCH 02/15] jsonrpc: Make dict returns from plain objects work --- mopidy/utils/jsonrpc.py | 4 +++- tests/utils/jsonrpc_test.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 17074239..d8000332 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import json import traceback +import pykka + class JsonRpcWrapper(object): """ @@ -167,7 +169,7 @@ class JsonRpcWrapper(object): return 'id' not in request def _is_future(self, result): - return callable(getattr(result, 'get', None)) + return isinstance(result, pykka.Future) class JsonRpcError(Exception): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 68950b59..c9972d96 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -22,6 +22,12 @@ class Calculator(object): 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 _secret(self): return 'Grand Unified Theory' @@ -118,6 +124,20 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): 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': 'calculator.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', From 61d6de237852f77e6cba84fd86b018ac7414ada8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 22 Nov 2012 15:30:34 +0100 Subject: [PATCH 03/15] jsonrpc: Support methods on the root object --- mopidy/utils/jsonrpc.py | 26 +++++++++++++++----------- tests/utils/jsonrpc_test.py | 28 ++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index d8000332..46d5f58e 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -8,18 +8,23 @@ import pykka class JsonRpcWrapper(object): """ - Wraps objects and make them accessible through JSON-RPC 2.0 messaging. + Wraps an object and makes it accessible through JSON-RPC 2.0 messaging. - This class takes responsibility of communicating with the objects and + This class takes responsibility of communicating with the object 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. + Only the object's public methods 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 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. + To expose multiple objects, simply create a new "parent" object and assign + the other objects you want to expose to attributes on the "parent" object. + You then wrap the "parent" object. + + 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 @@ -27,8 +32,8 @@ class JsonRpcWrapper(object): :param objects: dict of names mapped to objects to be exposed """ - def __init__(self, objects, decoders=None, encoders=None): - self.objects = objects + def __init__(self, obj, decoders=None, encoders=None): + self.obj = obj self.decoder = get_combined_json_decoder(decoders or []) self.encoder = get_combined_json_encoder(encoders or []) @@ -155,8 +160,7 @@ class JsonRpcWrapper(object): def _get_method(self, name): try: path = name.split('.') - root = path.pop(0) - this = self.objects[root] + this = self.obj for part in path: if part.startswith('_'): raise AttributeError diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index c9972d96..022525f4 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -12,6 +12,10 @@ from mopidy.utils import jsonrpc from tests import unittest +class ExportedObject(object): + pass + + class Calculator(object): def model(self): return 'TI83' @@ -36,11 +40,14 @@ class JsonRpcTestBase(unittest.TestCase): def setUp(self): self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() + + exported = ExportedObject() + exported.hello = lambda: 'Hello, world!' + exported.core = self.core + exported.calculator = Calculator() + self.jrw = jsonrpc.JsonRpcWrapper( - objects={ - 'core': self.core, - 'calculator': Calculator(), - }, + obj=exported, encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) @@ -111,6 +118,19 @@ class JsonRpcSerializationTest(JsonRpcTestBase): 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', From 29e2178a79c4063ddcd5bae176d3b2de9d3f6bca Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 22 Nov 2012 15:31:03 +0100 Subject: [PATCH 04/15] jsonrpc: Document encoders/decoders arguments --- mopidy/utils/jsonrpc.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 46d5f58e..a003567d 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -29,7 +29,12 @@ class JsonRpcWrapper(object): 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 + :param obj: object to be exposed + :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, obj, decoders=None, encoders=None): From 569ee6c5f3391983b73671c2ed6d43d6cbeb3266 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 24 Nov 2012 00:46:20 +0100 Subject: [PATCH 05/15] jsonrpc: Add inspector that describes the available API --- mopidy/utils/jsonrpc.py | 82 +++++++++++++++++++++++++++++++++ tests/utils/jsonrpc_test.py | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index a003567d..d3760d88 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import inspect import json import traceback @@ -245,3 +246,84 @@ def get_combined_json_encoder(encoders): pass # Try next encoder return json.JSONEncoder.default(self, obj) return JsonRpcEncoder + + +class JsonRpcInspector(object): + """ + Inspects a group of objects to create a description of what methods they + can expose over JSON-RPC 2.0. + + :param objects: mapping between mounts and exposed 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 diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 022525f4..37a3b91a 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -21,6 +21,7 @@ class Calculator(object): return 'TI83' def add(self, a, b): + """Returns the sum of the given numbers""" return a + b def sub(self, a, b): @@ -32,6 +33,9 @@ class Calculator(object): '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' @@ -491,3 +495,91 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): 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) From 40f4a8181dd5c717ff1982092ac28c74ac105a74 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 08:01:24 +0100 Subject: [PATCH 06/15] jsonrpc: Wrapper takes a mapping between mounts and objects This is analogous to how the inspector takes a mapping between mounts and classes. --- mopidy/utils/jsonrpc.py | 80 +++++++++++++++++++++++++++++++------ tests/utils/jsonrpc_test.py | 23 +++++------ 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index d3760d88..78f1c2b3 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -9,20 +9,38 @@ import pykka class JsonRpcWrapper(object): """ - Wraps an object and makes it accessible through JSON-RPC 2.0 messaging. + Wrap objects and make them accessible through JSON-RPC 2.0 messaging. - This class takes responsibility of communicating with the object and + 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. - Only the object's public methods will be exposed. Attributes are not + 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. - To expose multiple objects, simply create a new "parent" object and assign - the other objects you want to expose to attributes on the "parent" object. - You then wrap the "parent" object. + 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() 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. @@ -30,7 +48,9 @@ class JsonRpcWrapper(object): For further details on the JSON-RPC 2.0 spec, see http://www.jsonrpc.org/specification - :param obj: object to be exposed + :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` @@ -38,11 +58,33 @@ class JsonRpcWrapper(object): method :meth:`default` implemented """ - def __init__(self, obj, decoders=None, encoders=None): - self.obj = obj + 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. @@ -250,10 +292,24 @@ def get_combined_json_encoder(encoders): class JsonRpcInspector(object): """ - Inspects a group of objects to create a description of what methods they - can expose over JSON-RPC 2.0. + Inspects a group of classes and functions to create a description of what + methods they can expose over JSON-RPC 2.0. - :param objects: mapping between mounts and exposed classes + To inspect a single class, add it to the objects mapping using the empty + string as the key:: + + jri = JsonRpcInspector(objects={'': MyClas}) + + 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, + }) + + :param objects: mapping between mounts and exposed functions or classes :type objects: dict """ diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 37a3b91a..39ad8a81 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -12,10 +12,6 @@ from mopidy.utils import jsonrpc from tests import unittest -class ExportedObject(object): - pass - - class Calculator(object): def model(self): return 'TI83' @@ -45,13 +41,12 @@ class JsonRpcTestBase(unittest.TestCase): self.backend = dummy.DummyBackend.start(audio=None).proxy() self.core = core.Core.start(backends=[self.backend]).proxy() - exported = ExportedObject() - exported.hello = lambda: 'Hello, world!' - exported.core = self.core - exported.calculator = Calculator() - self.jrw = jsonrpc.JsonRpcWrapper( - obj=exported, + objects={ + 'hello': lambda: 'Hello, world!', + 'core': self.core, + '': Calculator(), + }, encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) @@ -135,10 +130,10 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): self.assertNotIn('error', response) self.assertEqual(response['result'], 'Hello, world!') - def test_call_method_on_plain_object(self): + def test_call_method_on_plain_object_as_root(self): request = { 'jsonrpc': '2.0', - 'method': 'calculator.model', + 'method': 'model', 'id': 1, } response = self.jrw.handle_data(request) @@ -151,7 +146,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_which_returns_dict_from_plain_object(self): request = { 'jsonrpc': '2.0', - 'method': 'calculator.describe', + 'method': 'describe', 'id': 1, } response = self.jrw.handle_data(request) @@ -400,7 +395,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): def test_private_method_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', - 'method': 'calculator._secret', + 'method': '_secret', 'id': 1, } response = self.jrw.handle_data(request) From 5a05b4af9df8bd59eab88ec34789923fd3dafc56 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 08:20:39 +0100 Subject: [PATCH 07/15] jsonrpc: Doc diff between wrapper and inspector --- mopidy/utils/jsonrpc.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 78f1c2b3..23f50580 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -15,11 +15,6 @@ class JsonRpcWrapper(object): 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. - 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. - To expose a single object, add it to the objects mapping using the empty string as the key:: @@ -42,6 +37,11 @@ class JsonRpcWrapper(object): 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. @@ -298,7 +298,7 @@ class JsonRpcInspector(object): To inspect a single class, add it to the objects mapping using the empty string as the key:: - jri = JsonRpcInspector(objects={'': MyClas}) + 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:: @@ -309,6 +309,14 @@ class JsonRpcInspector(object): '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 """ From 7f987cb1e25827a6cb78f38d53de201450e6e085 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 20:36:04 +0100 Subject: [PATCH 08/15] jsonrpc: Lookup methods using the objects map directly --- mopidy/utils/jsonrpc.py | 61 ++++++++++++------------------------- tests/utils/jsonrpc_test.py | 2 ++ 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 23f50580..c280fc27 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -37,10 +37,7 @@ class JsonRpcWrapper(object): 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. + Only the public methods of the mounted objects 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. @@ -59,32 +56,10 @@ class JsonRpcWrapper(object): """ def __init__(self, objects, decoders=None, encoders=None): - self.obj = self._build_exported_object(objects) + self.objects = 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. @@ -205,15 +180,21 @@ class JsonRpcWrapper(object): raise JsonRpcInvalidRequestError( data='"params", if given, must be an array or an object') - def _get_method(self, name): + 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 '.' in method_path: + mount, method_name = method_path.rsplit('.', 1) + else: + mount, method_name = '', method_path try: - path = name.split('.') - this = self.obj - for part in path: - if part.startswith('_'): - raise AttributeError - this = getattr(this, part) - return this + if method_name.startswith('_'): + raise AttributeError + obj = self.objects[mount] + return getattr(obj, method_name) except (AttributeError, KeyError): raise JsonRpcMethodNotFoundError() @@ -309,13 +290,9 @@ class JsonRpcInspector(object): '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. + 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 diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 39ad8a81..ea99a8f3 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -45,6 +45,8 @@ class JsonRpcTestBase(unittest.TestCase): objects={ 'hello': lambda: 'Hello, world!', 'core': self.core, + 'core.playback': self.core.playback, + 'core.tracklist': self.core.tracklist, '': Calculator(), }, encoders=[models.ModelJSONEncoder], From 869fcd2d8e43253e1e40432d0e55219daee1b58b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 20:42:48 +0100 Subject: [PATCH 09/15] jsonrpc: Move future handling code to its own method --- mopidy/utils/jsonrpc.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index c280fc27..4287a94d 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -126,8 +126,7 @@ class JsonRpcWrapper(object): if self._is_notification(request): return None - if self._is_future(result): - result = result.get() + result = self._unwrap_result(result) return { 'jsonrpc': '2.0', @@ -201,8 +200,10 @@ class JsonRpcWrapper(object): def _is_notification(self, request): return 'id' not in request - def _is_future(self, result): - return isinstance(result, pykka.Future) + def _unwrap_result(self, result): + if isinstance(result, pykka.Future): + result = result.get() + return result class JsonRpcError(Exception): From 253695222be212919e3621d157b97d173ece40f0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 20:58:34 +0100 Subject: [PATCH 10/15] jsonrpc: Clearify usage docs --- mopidy/utils/jsonrpc.py | 51 +++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 4287a94d..aa6e42ce 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -15,29 +15,46 @@ class JsonRpcWrapper(object): 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:: + The wrapper supports exporting the methods of multiple objects. If so, they + must be exported with different prefixes, called "mounts". - jrw = JsonRpcWrapper(objects={'': my_object}) + - To expose a single object, add it to the objects mapping using the empty + string as the mount:: - 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={'': my_object}) - jrw = JsonRpcWrapper(objects={ - '': foo, - 'hello': lambda: 'Hello, world!', - 'abc': abc, - }) + If ``my_object`` got a method named ``my_method()`` will be exported as + the JSON-RPC 2.0 method name ``my_method``. - This will create the following mapping between JSON-RPC 2.0 method names - and Python callables:: + - 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:: - bar -> foo.bar() - baz -> foo.baz() - hello -> lambda - abc.def -> abc.def() + jrw = JsonRpcWrapper(objects={ + '': foo, + 'hello': lambda: 'Hello, world!', + 'abc': abc, + }) - Only the public methods of the mounted objects will be exposed. + This will export the Python callables on the left as the JSON-RPC 2.0 + method names on the right:: + + foo.bar() -> bar + foo.baz() -> baz + lambda -> hello + abc.def() -> abc.def + + If the ``foo`` object mounted at the root also got a method named + ``hello``, there will be a name collision between ``foo.hello()`` and the + lambda function mounted at ``hello``. In that case, the JSON-RPC 2.0 + method name ``hello`` will refer to the lambda function, because it was + mounted explicitly as ``hello``. + + It is recommended to avoid name collisions entirely by using non-empty + mounts for all objects. + + 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. From 609fdc46ca649f4dec5fa1ce49f4093a1d9f43c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 21:13:55 +0100 Subject: [PATCH 11/15] jsonrpc: Explain why call to private method failed --- mopidy/utils/jsonrpc.py | 5 +++-- tests/utils/jsonrpc_test.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index aa6e42ce..4bcf3a1c 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -206,9 +206,10 @@ class JsonRpcWrapper(object): mount, method_name = method_path.rsplit('.', 1) else: mount, method_name = '', method_path + if method_name.startswith('_'): + raise JsonRpcMethodNotFoundError( + data='Private methods are not exported') try: - if method_name.startswith('_'): - raise AttributeError obj = self.objects[mount] return getattr(obj, method_name) except (AttributeError, KeyError): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index ea99a8f3..fa9d2b4c 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -405,6 +405,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): 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 = { From 50814d3929b15c7077ea423e34ee7b0c4c31f8fc Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 21:23:55 +0100 Subject: [PATCH 12/15] jsonrpc: Explain why a method wasn't found --- mopidy/utils/jsonrpc.py | 14 ++++++++++++-- tests/utils/jsonrpc_test.py | 20 +++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 4bcf3a1c..9aed38cd 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -202,18 +202,28 @@ class JsonRpcWrapper(object): return self.objects[method_path] # The mounted object contains the callable + if '.' in method_path: mount, method_name = method_path.rsplit('.', 1) else: mount, method_name = '', method_path + 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, KeyError): - raise JsonRpcMethodNotFoundError() + 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 diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index fa9d2b4c..8c487e87 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -381,11 +381,10 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): self.assertEqual( error['data'], '"params", if given, must be an array or an object') - def test_unknown_method_causes_unknown_method_error(self): + def test_method_on_unknown_object_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', - 'method': 'bogus', - 'params': ['bogus'], + 'method': 'bogus.bogus', 'id': 1, } response = self.jrw.handle_data(request) @@ -393,6 +392,21 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): 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 = { From b33df8200a8e2dc932655c505728ca16c1b5bb40 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 21:25:00 +0100 Subject: [PATCH 13/15] jsonrpc: Grammar --- mopidy/utils/jsonrpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 9aed38cd..d31cccbd 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -23,7 +23,7 @@ class JsonRpcWrapper(object): jrw = JsonRpcWrapper(objects={'': my_object}) - If ``my_object`` got a method named ``my_method()`` will be exported as + If ``my_object`` has a method named ``my_method()`` will be exported as the JSON-RPC 2.0 method name ``my_method``. - To expose multiple objects, add them all to the objects mapping. The key From 8f604204dad138fb3dd3078fe566e174aebc56ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 21:40:12 +0100 Subject: [PATCH 14/15] jsonrpc: Don't allow objects at the root --- mopidy/utils/jsonrpc.py | 66 ++++++++++++++----------------------- tests/utils/jsonrpc_test.py | 46 +++++++++++++------------- 2 files changed, 47 insertions(+), 65 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index d31cccbd..47b660d3 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -15,43 +15,24 @@ class JsonRpcWrapper(object): 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 multiple objects. If so, they - must be exported with different prefixes, called "mounts". + 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 a single object, add it to the objects mapping using the empty - string as the mount:: + 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={'': my_object}) + jrw = JsonRpcWrapper(objects={ + 'foo': foo, + 'hello': lambda: 'Hello, world!', + }) - If ``my_object`` has a method named ``my_method()`` will be exported as - the JSON-RPC 2.0 method name ``my_method``. + This will export the Python callables on the left as the JSON-RPC 2.0 + method names on the right:: - - 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 export the Python callables on the left as the JSON-RPC 2.0 - method names on the right:: - - foo.bar() -> bar - foo.baz() -> baz - lambda -> hello - abc.def() -> abc.def - - If the ``foo`` object mounted at the root also got a method named - ``hello``, there will be a name collision between ``foo.hello()`` and the - lambda function mounted at ``hello``. In that case, the JSON-RPC 2.0 - method name ``hello`` will refer to the lambda function, because it was - mounted explicitly as ``hello``. - - It is recommended to avoid name collisions entirely by using non-empty - mounts for all objects. + 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. @@ -73,6 +54,9 @@ class JsonRpcWrapper(object): """ 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 []) @@ -305,18 +289,13 @@ 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:: + 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': Foo, 'hello': lambda: 'Hello, world!', - 'abc': Abc, }) Since the inspector is based on inspecting classes and not instances, it @@ -328,6 +307,9 @@ class JsonRpcInspector(object): """ 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): diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 8c487e87..d4730be2 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -44,10 +44,10 @@ class JsonRpcTestBase(unittest.TestCase): self.jrw = jsonrpc.JsonRpcWrapper( objects={ 'hello': lambda: 'Hello, world!', + 'calc': Calculator(), 'core': self.core, 'core.playback': self.core.playback, 'core.tracklist': self.core.tracklist, - '': Calculator(), }, encoders=[models.ModelJSONEncoder], decoders=[models.model_json_decoder]) @@ -56,6 +56,12 @@ class JsonRpcTestBase(unittest.TestCase): 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() @@ -132,10 +138,10 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): self.assertNotIn('error', response) self.assertEqual(response['result'], 'Hello, world!') - def test_call_method_on_plain_object_as_root(self): + def test_call_method_on_plain_object(self): request = { 'jsonrpc': '2.0', - 'method': 'model', + 'method': 'calc.model', 'id': 1, } response = self.jrw.handle_data(request) @@ -148,7 +154,7 @@ class JsonRpcSingleCommandTest(JsonRpcTestBase): def test_call_method_which_returns_dict_from_plain_object(self): request = { 'jsonrpc': '2.0', - 'method': 'describe', + 'method': 'calc.describe', 'id': 1, } response = self.jrw.handle_data(request) @@ -510,6 +516,10 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): 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!', @@ -520,24 +530,24 @@ class JsonRpcInspectorTest(JsonRpcTestBase): self.assertIn('hello', methods) self.assertEqual(len(methods['hello']['params']), 0) - def test_inspector_can_describe_methods_on_the_root_class(self): + def test_inspector_can_describe_an_object_with_methods(self): inspector = jsonrpc.JsonRpcInspector({ - '': Calculator, + 'calc': Calculator, }) methods = inspector.describe() - self.assertIn('add', methods) + self.assertIn('calc.add', methods) self.assertEqual( - methods['add']['description'], + methods['calc.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) + self.assertIn('calc.sub', methods) + self.assertIn('calc.take_it_all', methods) + self.assertNotIn('calc._secret', methods) + self.assertNotIn('calc.__init__', methods) - method = methods['take_it_all'] + method = methods['calc.take_it_all'] self.assertIn('params', method) params = method['params'] @@ -559,16 +569,6 @@ class JsonRpcInspectorTest(JsonRpcTestBase): 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, From 160626b3641efcc5708e50c6168a73fba3ccb806 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 25 Nov 2012 21:51:59 +0100 Subject: [PATCH 15/15] jsonrpc: Give explicit error if calling method without object path --- mopidy/utils/jsonrpc.py | 10 ++++++---- tests/utils/jsonrpc_test.py | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/mopidy/utils/jsonrpc.py b/mopidy/utils/jsonrpc.py index 47b660d3..8230aada 100644 --- a/mopidy/utils/jsonrpc.py +++ b/mopidy/utils/jsonrpc.py @@ -187,10 +187,12 @@ class JsonRpcWrapper(object): # The mounted object contains the callable - if '.' in method_path: - mount, method_name = method_path.rsplit('.', 1) - else: - mount, method_name = '', method_path + 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( diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index d4730be2..64b5e628 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -387,6 +387,21 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): 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', @@ -417,7 +432,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): def test_private_method_causes_unknown_method_error(self): request = { 'jsonrpc': '2.0', - 'method': '_secret', + 'method': 'core._secret', 'id': 1, } response = self.jrw.handle_data(request)