├── __init__.py ├── tests ├── __init__.py └── test_json_rpc.py ├── .gitignore ├── LICENSE ├── setup.py ├── README.md └── json_rpc.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Fabio Oliveira Costa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-JSON-RPC-2 3 | ------------- 4 | 5 | Lightweight flask extension for json rpc 2.0 method handling 6 | It is capable of handle urls without bounding method, urls bounding methods 7 | and handling using a class 8 | """ 9 | from setuptools import setup 10 | 11 | setup( 12 | name='flask_json_rpc2', 13 | version='0.1', 14 | url='https://github.com/drFabio/flask_json_rpc2', 15 | license='MIT', 16 | author='Fabio Costa', 17 | author_email='blackjackdevel@gmail.com', 18 | description='Adds json rpc 2 capabilities', 19 | long_description=__doc__, 20 | py_modules=['json_rpc'], 21 | zip_safe=False, 22 | include_package_data=True, 23 | platforms='any', 24 | install_requires=[ 25 | 'Flask' 26 | ], 27 | classifiers=[ 28 | 'Environment :: Web Environment', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 34 | 'Topic :: Software Development :: Libraries :: Python Modules' 35 | ] 36 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lightweight JSON RPC 2.0 extension for flask 2 | 3 | ## Examples 4 | 5 | ### Methods 6 | 7 | You can handle all methods on a single end point 8 | ```python 9 | @app.route('/hello', methods=['POST']) 10 | @rpc_request 11 | def func(method): 12 | return 'Hello from '+method 13 | ``` 14 | 15 | Or you can just listen on a specific method 16 | 17 | ```python 18 | @app.route('/with_method', methods=['POST']) 19 | @rpc_request(rpc_method='fixed_method') 20 | def func_with_method(): 21 | return 'Hello from fixed method' 22 | ``` 23 | 24 | Also you are able to handle params from the JSON RPC 2.0 with or without a method 25 | 26 | ```python 27 | @app.route('/no_method_and_params', methods=['POST']) 28 | @rpc_request 29 | def func_with_params(method, sum_a, sum_b): 30 | return 'Hello from '+method+' and your sum is %d' % (sum_a+sum_b) 31 | 32 | @app.route('/with_method_and_params', methods=['POST']) 33 | @rpc_request(rpc_method='another_fixed_method') 34 | def func_with_method_and_params(diff_a, diff_b): 35 | return ('Hello from another_fixed_method ' 36 | 'and your diff is %d' % (diff_a-diff_b)) 37 | ``` 38 | 39 | ### Classes 40 | The class bellow will listen on the methods hello, sum and error 41 | ```python 42 | class MyHandler(RPCHandler): 43 | 44 | def hello(self): 45 | return 'Hello from hello' 46 | 47 | def sum(self, a, b): 48 | return a+b 49 | 50 | def error(self): 51 | raise Exception('SOME ERROR') 52 | ``` 53 | 54 | After the class creation it is needed to be registered on some end point 55 | 56 | ```python 57 | handler = MyHandler() 58 | handler.register('/class', app) 59 | ``` -------------------------------------------------------------------------------- /tests/test_json_rpc.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from flask import Flask, json 3 | from flask_json_rpc2.json_rpc import ( 4 | rpc_request, 5 | ErrorCode, 6 | RPCHandler, 7 | RPCError) 8 | 9 | 10 | class MyHandler(RPCHandler): 11 | 12 | def hello(self): 13 | return 'Hello from hello' 14 | 15 | def sum(self, a, b): 16 | return a+b 17 | 18 | def error(self): 19 | raise Exception('SOME ERROR') 20 | 21 | 22 | def json_rpc_request(client, url, method, params=None, id=123): 23 | resp = client.post( 24 | url, 25 | data=json.dumps({ 26 | 'jsonrpc': '2.0', 27 | 'id': id, 28 | 'method': method, 29 | 'params': params 30 | }), 31 | content_type='application/json' 32 | ) 33 | response = json.loads(resp.get_data()) 34 | return response 35 | 36 | 37 | class JSONRPC_Test(unittest.TestCase): 38 | 39 | def _create_app(self): 40 | app = Flask(__name__) 41 | app.debug = True 42 | 43 | @app.route('/hello', methods=['POST']) 44 | @rpc_request 45 | def func(method): 46 | return 'Hello from '+method 47 | 48 | @app.route('/no_method_and_params', methods=['POST']) 49 | @rpc_request 50 | def func_with_params(method, sum_a, sum_b): 51 | return 'Hello from '+method+' and your sum is %d' % (sum_a+sum_b) 52 | 53 | @app.route('/with_method', methods=['POST']) 54 | @rpc_request(rpc_method='fixed_method') 55 | def func_with_method(): 56 | return 'Hello from fixed method' 57 | 58 | @app.route('/with_method_and_params', methods=['POST']) 59 | @rpc_request(rpc_method='another_fixed_method') 60 | def func_with_method_and_params(diff_a, diff_b): 61 | return ('Hello from another_fixed_method ' 62 | 'and your diff is %d' % (diff_a-diff_b)) 63 | 64 | @app.route('/with_exception', methods=['POST']) 65 | @rpc_request 66 | def func_with_exception(method): 67 | raise RPCError(message='SOME ERROR') 68 | handler = MyHandler() 69 | handler.register('/class', app) 70 | return app 71 | 72 | def request(self, url, method, params=None, id=123): 73 | response = json_rpc_request(self.client, url, method, params, id) 74 | self.assertEqual('2.0', str(response['jsonrpc'])) 75 | if id is not None: 76 | self.assertEqual(id, response['id']) 77 | self.assertTrue(any(k in response for k in ('result', 'error'))) 78 | return response 79 | 80 | def setUp(self): 81 | app = self._create_app() 82 | self.client = app.test_client() 83 | 84 | def _test_rpc_error(self, response, expected_code, expected_message=None): 85 | self.assertIn('error', response) 86 | error_code = response['error']['code'] 87 | self.assertEqual(error_code, expected_code) 88 | message = response['error']['message'] 89 | self.assertIsNotNone(message) 90 | if expected_message: 91 | self.assertEqual(message, expected_message) 92 | 93 | def test_error_no_id(self): 94 | response = self.request('/hello', 'hi', id=None) 95 | self._test_rpc_error(response, ErrorCode.invalid_request.value) 96 | 97 | def test_error_wrong_params(self): 98 | """ 99 | Params should ALWAYS be a list or dict 100 | """ 101 | response = self.request('/no_method_and_params', 'sum', 2) 102 | self._test_rpc_error(response, ErrorCode.invalid_param.value) 103 | 104 | def test_function(self): 105 | response = self.request('/hello', 'hi') 106 | self.assertEqual(response['result'], 'Hello from hi') 107 | 108 | def test_function_with_params(self): 109 | response = self.request('/no_method_and_params', 'sum', [1, 2]) 110 | self.assertEqual( 111 | response['result'], 'Hello from sum and your sum is 3') 112 | 113 | def test_function_with_pos_params(self): 114 | response = self.request( 115 | '/no_method_and_params', 'sum', {'sum_a': 3, 'sum_b': 1}) 116 | self.assertEqual( 117 | response['result'], 'Hello from sum and your sum is 4') 118 | 119 | def test_function_with_method(self): 120 | response = self.request('/with_method', 'fixed_method') 121 | self.assertEqual(response['result'], 'Hello from fixed method') 122 | 123 | def test_function_with_method_and_params(self): 124 | response = self.request( 125 | '/with_method_and_params', 'another_fixed_method', [2, 1]) 126 | expected = 'Hello from another_fixed_method and your diff is 1' 127 | self.assertEqual(response['result'], expected) 128 | 129 | def test_fixed_method_wrong_method(self): 130 | response = self.request('/with_method', 'non_existant') 131 | self._test_rpc_error(response, ErrorCode.method_not_found.value) 132 | 133 | def test_class_no_params(self): 134 | response = self.request('/class', 'hello') 135 | self.assertEqual(response['result'], 'Hello from hello') 136 | 137 | def test_with_exception(self): 138 | response = self.request('/with_exception', 'some_method') 139 | self._test_rpc_error( 140 | response, ErrorCode.internal_error.value, 'SOME ERROR') 141 | -------------------------------------------------------------------------------- /json_rpc.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from flask import request, current_app 3 | from flask import jsonify 4 | from functools import wraps 5 | import sys 6 | from bson.json_util import dumps 7 | 8 | 9 | class RPCHandler: 10 | 11 | """ 12 | Handles RPC methods 13 | """ 14 | 15 | def _call_method(self, method, *args, **kwargs): 16 | """ 17 | Class the method on the class, 18 | raise RPCError otherwise 19 | Args: 20 | method (string): the method coming from the request 21 | """ 22 | method_func = getattr(self, method, None) 23 | if not callable(method_func): 24 | raise RPCError() 25 | return method_func(*args, **kwargs) 26 | 27 | def _handle_route(self): 28 | return _handle_request(self._call_method) 29 | 30 | def register(self, url, app): 31 | """ 32 | Register the class to handle RPC requests on the url 33 | Args: 34 | url (string): Url to handle 35 | app (string): Flask app like object (could be Blueprnt) 36 | """ 37 | app.route(url, methods=['POST'])(self._handle_route) 38 | 39 | 40 | def rpc_request(*args, **kwargs): 41 | """ 42 | Decorator for rpc_requests 43 | """ 44 | defined_method = None 45 | 46 | def func_wrapper(func): 47 | @wraps(func) 48 | def wrapper(): 49 | return _handle_request(func, defined_method) 50 | return wrapper 51 | 52 | if len(args) == 1 and callable(args[0]): 53 | return func_wrapper(args[0]) 54 | else: 55 | if 'rpc_method' in kwargs: 56 | defined_method = kwargs['rpc_method'] 57 | return func_wrapper 58 | 59 | def _handle_request(func, defined_method=None): 60 | """ 61 | Handles and validate a request 62 | Args: 63 | func (function): Function to call if request ok 64 | defined_method (string): only accept method if present 65 | """ 66 | data = request.get_json(force=True, silent=True) 67 | fields = ['jsonrpc', 'method', 'id'] 68 | not_2 = str(data['jsonrpc']) != '2.0' 69 | missing_required = not all( 70 | k in data and data[k] is not None for k in fields) 71 | if missing_required or not_2: 72 | error_code = ErrorCode.invalid_request.value 73 | response = _build_error( 74 | error_code, 'Invalid json rpc request', rpc_id=data.get('id')) 75 | return _send_response(response) 76 | 77 | list_args = [] 78 | dict_args = {} 79 | 80 | if 'params' in data and data['params'] is not None: 81 | if isinstance(data['params'], dict): 82 | dict_args = data['params'] 83 | elif isinstance(data['params'], list): 84 | list_args = data['params'] 85 | else: 86 | error_code = ErrorCode.invalid_param.value 87 | response = _build_error( 88 | error_code, 'Invalid json rpc request', None, data.get('id')) 89 | return _send_response(response) 90 | sent_method = data['method'] 91 | 92 | if defined_method: 93 | if sent_method != defined_method: 94 | error_code = ErrorCode.method_not_found.value 95 | response = _build_error( 96 | error_code, 'Method not found', None, data.get('id')) 97 | return _send_response(response) 98 | else: 99 | list_args = [sent_method] + list_args 100 | # try: 101 | response = func(*list_args, **dict_args) 102 | # except BaseException as e: 103 | # print("FLASK JSON RPC EXCEPTION!") 104 | # print(e) 105 | # return _handle_exception(e, data.get('id')) 106 | 107 | return _send_response(_build_response(data['id'], response)) 108 | 109 | 110 | class ErrorCode(Enum): 111 | 112 | """ 113 | JSON RPC default error Codes 114 | """ 115 | parse_error = -32700 116 | invalid_request = -32600 117 | method_not_found = -32601 118 | invalid_param = -32602 119 | internal_error = -32603 120 | 121 | class RPCError(BaseException): 122 | __slots__ = ['error_code', 'message'] 123 | internal_error = ErrorCode.internal_error.value 124 | 125 | def __init__(self, error_code=internal_error, message="Internal error"): 126 | self.error_code = error_code 127 | self.message = message 128 | super().__init__() 129 | 130 | def _send_response(response): 131 | """ 132 | Creates a response based on the object 133 | Args: 134 | response: A json serializable response 135 | Returns: 136 | flask.Response : with status code 200 always 137 | """ 138 | resp = current_app.response_class((dumps(response), '\n'),mimetype='application/json') 139 | resp.status_code = 200 140 | return resp 141 | 142 | def _build_error(error_code, message, data=None, rpc_id=None): 143 | """ 144 | Build a JSON RPC error dict 145 | Args: 146 | error_code (int): JSON or app error code 147 | message (string): Message to display 148 | data : Json serializable data 149 | rpc_id (int): The request id 150 | Returns: 151 | dict the json_rpc error response as dict 152 | """ 153 | resp = { 154 | 'jsonrpc': '2.0', 155 | 'id': rpc_id or 'null', 156 | 'error': { 157 | 'code': error_code, 158 | 'message': message, 159 | 'data': None 160 | } 161 | 162 | } 163 | return resp 164 | 165 | def _build_response(rpc_id, result): 166 | """ 167 | Build a JSON response 168 | Args: 169 | rpc_id (int): The request id 170 | result : Json serializable data 171 | Returns: 172 | dict the json_rpc success response as dict 173 | """ 174 | resp = { 175 | 'jsonrpc': '2.0', 176 | 'id': rpc_id, 177 | 'result': result 178 | } 179 | return resp 180 | 181 | def _handle_exception(error, rpc_id): 182 | """ 183 | Turns an exception into json rpc error 184 | Args: 185 | error (Exception) 186 | rpc_id (int): The request id 187 | """ 188 | error_code = getattr(error, 'error_code', ErrorCode.internal_error.value) 189 | message = getattr(error, 'message', "Internal error") 190 | response = _build_error( 191 | error_code, message, None, rpc_id) 192 | 193 | return _send_response(response) --------------------------------------------------------------------------------