diff --git a/.gitignore b/.gitignore index 0fae7d6..1e234e1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ dist/ docs/_build/ +.idea diff --git a/jsonrpc/__init__.py b/jsonrpc/__init__.py index cc5680a..95a8fe0 100644 --- a/jsonrpc/__init__.py +++ b/jsonrpc/__init__.py @@ -1,4 +1,5 @@ from .manager import JSONRPCResponseManager +from .managerasync import JSONRPCResponseManagerAsync from .dispatcher import Dispatcher __version = (1, 15, 0) diff --git a/jsonrpc/managerasync.py b/jsonrpc/managerasync.py new file mode 100644 index 0000000..3fba9cc --- /dev/null +++ b/jsonrpc/managerasync.py @@ -0,0 +1,144 @@ +import json +import logging +from .utils import is_invalid_params +from .exceptions import ( + JSONRPCInvalidParams, + JSONRPCInvalidRequest, + JSONRPCInvalidRequestException, + JSONRPCMethodNotFound, + JSONRPCParseError, + JSONRPCServerError, + JSONRPCDispatchException, +) +from .jsonrpc1 import JSONRPC10Response +from .jsonrpc2 import ( + JSONRPC20BatchRequest, + JSONRPC20BatchResponse, + JSONRPC20Response, +) +from .jsonrpc import JSONRPCRequest + +logger = logging.getLogger(__name__) + + +class JSONRPCResponseManagerAsync(object): + """ JSON-RPC response manager. + + Method brings syntactic sugar into library. Given dispatcher it handles + request (both single and batch) and handles errors. + Request could be handled in parallel, it is server responsibility. + + TODO refactor later, this is copy paste of manager.py with async added to methods + + :param str request_str: json string. Will be converted into + JSONRPC20Request, JSONRPC20BatchRequest or JSONRPC10Request + + :param dict dispatcher: dict. + + """ + + RESPONSE_CLASS_MAP = { + "1.0": JSONRPC10Response, + "2.0": JSONRPC20Response, + } + + @classmethod + async def handle(cls, request_str, dispatcher, context=None): + if isinstance(request_str, bytes): + request_str = request_str.decode("utf-8") + + try: + data = json.loads(request_str) + except (TypeError, ValueError): + return JSONRPC20Response(error=JSONRPCParseError()._data) + + try: + request = JSONRPCRequest.from_data(data) + except JSONRPCInvalidRequestException: + return JSONRPC20Response(error=JSONRPCInvalidRequest()._data) + + return await cls.handle_request(request, dispatcher, context) + + @classmethod + async def handle_request(cls, request, dispatcher, context=None): + """ Handle request data. + + At this moment request has correct jsonrpc format. + + :param dict request: data parsed from request_str. + :param jsonrpc.dispatcher.Dispatcher dispatcher: + + .. versionadded: 1.8.0 + + """ + rs = request if isinstance(request, JSONRPC20BatchRequest) \ + else [request] + responses = [r async for r in cls._get_responses(rs, dispatcher, context) + if r is not None] + + # notifications + if not responses: + return + + if isinstance(request, JSONRPC20BatchRequest): + response = JSONRPC20BatchResponse(*responses) + response.request = request + return response + else: + return responses[0] + + @classmethod + async def _get_responses(cls, requests, dispatcher, context=None): + """ Response to each single JSON-RPC Request. + + :return iterator(JSONRPC20Response): + + .. versionadded: 1.9.0 + TypeError inside the function is distinguished from Invalid Params. + + """ + for request in requests: + def make_response(**kwargs): + response = cls.RESPONSE_CLASS_MAP[request.JSONRPC_VERSION]( + _id=request._id, **kwargs) + response.request = request + return response + + output = None + try: + method = dispatcher[request.method] + except KeyError: + output = make_response(error=JSONRPCMethodNotFound()._data) + else: + try: + kwargs = request.kwargs + if context is not None: + context_arg = dispatcher.context_arg_for_method.get( + request.method) + if context_arg: + context["request"] = request + kwargs[context_arg] = context + result = await method(*request.args, **kwargs) + except JSONRPCDispatchException as e: + output = make_response(error=e.error._data) + except Exception as e: + data = { + "type": e.__class__.__name__, + "args": e.args, + "message": str(e), + } + + logger.exception("API Exception: {0}".format(data)) + + if isinstance(e, TypeError) and is_invalid_params( + method, *request.args, **request.kwargs): + output = make_response( + error=JSONRPCInvalidParams(data=data)._data) + else: + output = make_response( + error=JSONRPCServerError(data=data)._data) + else: + output = make_response(result=result) + finally: + if not request.is_notification: + yield output diff --git a/jsonrpc/tests/test_managerasync.py b/jsonrpc/tests/test_managerasync.py new file mode 100644 index 0000000..cf69a24 --- /dev/null +++ b/jsonrpc/tests/test_managerasync.py @@ -0,0 +1,188 @@ +import sys + +from ..dispatcher import Dispatcher +from ..managerasync import JSONRPCResponseManagerAsync +from ..jsonrpc2 import ( + JSONRPC20BatchRequest, + JSONRPC20BatchResponse, + JSONRPC20Request, + JSONRPC20Response, +) +from ..jsonrpc1 import JSONRPC10Request, JSONRPC10Response +from ..exceptions import JSONRPCDispatchException + +if sys.version_info < (3, 3): + from mock import MagicMock +else: + from unittest.mock import MagicMock + +if sys.version_info < (2, 7): + import unittest2 as unittest +else: + import unittest + + +class TestJSONRPCResponseManagerAsync(unittest.TestCase): + def setUp(self): + def raise_(e): + raise e + + self.long_time_method = MagicMock() + self.dispatcher = Dispatcher() + self.dispatcher["add"] = sum + self.dispatcher["multiply"] = lambda a, b: a * b + self.dispatcher["list_len"] = len + self.dispatcher["101_base"] = lambda **kwargs: int("101", **kwargs) + self.dispatcher["error"] = lambda: raise_( + KeyError("error_explanation")) + self.dispatcher["type_error"] = lambda: raise_( + TypeError("TypeError inside method")) + self.dispatcher["long_time_method"] = self.long_time_method + self.dispatcher["dispatch_error"] = lambda x: raise_( + JSONRPCDispatchException(code=4000, message="error", + data={"param": 1})) + + @self.dispatcher.add_method(context_arg="context") + def return_json_rpc_id(context): + return context["request"]._id + + async def test_dispatch_error(self): + request = JSONRPC20Request("dispatch_error", ["test"], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "error") + self.assertEqual(response.error["code"], 4000) + self.assertEqual(response.error["data"], {"param": 1}) + + async def test_returned_type_response(self): + request = JSONRPC20Request("add", [[]], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + + async def test_returned_type_butch_response(self): + request = JSONRPC20BatchRequest( + JSONRPC20Request("add", [[]], _id=0)) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20BatchResponse)) + + async def test_returned_type_response_rpc10(self): + request = JSONRPC10Request("add", [[]], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC10Response)) + + async def test_parse_error(self): + req = '{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]' + response = await JSONRPCResponseManagerAsync.handle(req, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Parse error") + self.assertEqual(response.error["code"], -32700) + + async def test_invalid_request(self): + req = '{"jsonrpc": "2.0", "method": 1, "params": "bar"}' + response = await JSONRPCResponseManagerAsync.handle(req, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid Request") + self.assertEqual(response.error["code"], -32600) + + async def test_method_not_found(self): + request = JSONRPC20Request("does_not_exist", [[]], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Method not found") + self.assertEqual(response.error["code"], -32601) + + async def test_invalid_params(self): + request = JSONRPC20Request("add", {"a": 0}, _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + self.assertIn(response.error["data"]["message"], [ + 'sum() takes no keyword arguments', + "sum() got an unexpected keyword argument 'a'", + 'sum() takes at least 1 positional argument (0 given)', + ]) + + async def test_invalid_params_custom_function(self): + request = JSONRPC20Request("multiply", [0], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + + request = JSONRPC20Request("multiply", [0, 1, 2], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + + request = JSONRPC20Request("multiply", {"a": 1}, _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + + request = JSONRPC20Request("multiply", {"a": 1, "b": 2, "c": 3}, _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + + async def test_server_error(self): + request = JSONRPC20Request("error", _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Server error") + self.assertEqual(response.error["code"], -32000) + self.assertEqual(response.error["data"]['type'], "KeyError") + self.assertEqual( + response.error["data"]['args'], ('error_explanation',)) + self.assertEqual( + response.error["data"]['message'], "'error_explanation'") + + async def test_notification_calls_method(self): + request = JSONRPC20Request("long_time_method", is_notification=True) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertEqual(response, None) + self.long_time_method.assert_called_once_with() + + async def test_notification_does_not_return_error_does_not_exist(self): + request = JSONRPC20Request("does_not_exist", is_notification=True) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertEqual(response, None) + + async def test_notification_does_not_return_error_invalid_params(self): + request = JSONRPC20Request("add", {"a": 0}, is_notification=True) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertEqual(response, None) + + async def test_notification_does_not_return_error(self): + request = JSONRPC20Request("error", is_notification=True) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertEqual(response, None) + + async def test_type_error_inside_method(self): + request = JSONRPC20Request("type_error", _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Server error") + self.assertEqual(response.error["code"], -32000) + self.assertEqual(response.error["data"]['type'], "TypeError") + self.assertEqual( + response.error["data"]['args'], ('TypeError inside method',)) + self.assertEqual( + response.error["data"]['message'], 'TypeError inside method') + + async def test_invalid_params_before_dispatcher_error(self): + request = JSONRPC20Request( + "dispatch_error", ["invalid", "params"], _id=0) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher) + self.assertTrue(isinstance(response, JSONRPC20Response)) + self.assertEqual(response.error["message"], "Invalid params") + self.assertEqual(response.error["code"], -32602) + + async def test_setting_json_rpc_id_in_context(self): + request = JSONRPC20Request("return_json_rpc_id", _id=42) + response = await JSONRPCResponseManagerAsync.handle(request.json, self.dispatcher, + context={}) + self.assertEqual(response.data["result"], 42)