├── .github ├── dependabot.yml └── workflows │ └── code-quality.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── async.md ├── dispatch.md ├── examples.md ├── faq.md ├── images │ └── logo.png ├── index.md ├── installation.md └── methods.md ├── examples ├── aiohttp_server.py ├── aiozmq_server.py ├── asyncio_server.py ├── django_server.py ├── fastapi_server.py ├── flask_server.py ├── http_server.py ├── jsonrpcserver_server.py ├── sanic_server.py ├── socketio_server.py ├── tornado_server.py ├── websockets_server.py ├── werkzeug_server.py └── zeromq_server.py ├── jsonrpcserver ├── __init__.py ├── async_dispatcher.py ├── async_main.py ├── codes.py ├── dispatcher.py ├── exceptions.py ├── main.py ├── methods.py ├── py.typed ├── request-schema.json ├── request.py ├── response.py ├── result.py ├── sentinels.py ├── server.py └── utils.py ├── logo.png ├── mkdocs.yml ├── setup.py ├── tests ├── __init__.py ├── test_async_dispatcher.py ├── test_async_main.py ├── test_dispatcher.py ├── test_main.py ├── test_methods.py ├── test_request.py ├── test_response.py ├── test_result.py ├── test_sentinels.py └── test_server.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | on: [pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-python@v2 10 | with: 11 | python-version: 3.8 12 | - run: pip install --upgrade pip 13 | - run: pip install types-setuptools "black<23" "pylint<3" "mypy<1" "jsonschema<5" pytest "oslash<1" "aiohttp<4" "aiozmq<1" "django<5" "fastapi<1" "flask<3" "flask-socketio<5.3.1" "pyzmq" "sanic" "tornado<7" "uvicorn<1" "websockets<11" 14 | - run: black --diff --check $(git ls-files -- '*.py' ':!:docs/*') 15 | - run: pylint $(git ls-files -- '*.py' ':!:docs/*') 16 | - run: mypy --strict $(git ls-files -- '*.py' ':!:docs/*') 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *egg-info 2 | .coverage 3 | .coveralls.yml 4 | .mypy_cache 5 | .pytest_cache 6 | .tox 7 | .travis.yml 8 | dist/ 9 | design 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.8 3 | exclude: (^docs) 4 | fail_fast: true 5 | repos: 6 | - repo: https://github.com/ambv/black 7 | rev: 22.8.0 8 | hooks: 9 | - id: black 10 | args: [--diff, --check] 11 | 12 | - repo: https://github.com/PyCQA/pylint 13 | rev: v2.15.3 14 | hooks: 15 | - id: pylint 16 | additional_dependencies: 17 | - oslash<1 18 | - aiohttp<4 19 | - aiozmq<1 20 | - django<5 21 | - fastapi<1 22 | - flask<3 23 | - flask-socketio<5.3.1 24 | - jsonschema<5 25 | - pytest 26 | - pyzmq 27 | - sanic 28 | - tornado<7 29 | - uvicorn<1 30 | - websockets<11 31 | 32 | - repo: https://github.com/pre-commit/mirrors-mypy 33 | rev: v1.14.1 34 | hooks: 35 | - id: mypy 36 | args: [--strict] 37 | additional_dependencies: 38 | - aiohttp<4 39 | - aiozmq<1 40 | - django<5 41 | - fastapi<1 42 | - flask<3 43 | - flask-socketio<5.3.1 44 | - pytest 45 | - pyzmq 46 | - sanic 47 | - tornado<7 48 | - types-setuptools 49 | - uvicorn<1 50 | - websockets<11 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # jsonrpcserver Change Log 2 | 3 | ## 5.0.9 (Sep 15, 2022) 4 | 5 | - Remove unncessary `package_data` from setup.py (#243) 6 | - Use a custom logger when logging exceptions, not root 7 | 8 | ## 5.0.8 (Aug 16, 2022) 9 | 10 | - Use importlib.resources instead of pkg_resources. 11 | 12 | ## 5.0.7 (Mar 10, 2022) 13 | 14 | - Upgrade to jsonschema 4. 15 | 16 | ## 5.0.6 (Jan 14, 2022) 17 | 18 | - Fix reversed Result Either type ([#227](https://github.com/explodinglabs/jsonrpcserver/pull/227)). 19 | 20 | ## 5.0.5 (Nov 27, 2021) 21 | 22 | - Documentation. 23 | 24 | ## 5.0.4 (Oct 27, 2021) 25 | 26 | - Add to FAQ. 27 | 28 | ## 5.0.3 29 | 30 | - Update readme and documentation. 31 | - Internal function `compose` has been replaced with a better one. 32 | 33 | ## 5.0.2 34 | 35 | - Update readme and setup.py, minor adjustments. 36 | 37 | ## 5.0.0 (Aug 16, 2021) 38 | 39 | A complete rebuild, with a few important usage changes. 40 | 41 | See a post explaining the changes at 42 | [https://composed.blog/jsonrpcserver-5-changes](https://composed.blog/jsonrpcserver-5-changes). 43 | 44 | Read the full version 5 documentation at 45 | [jsonrpcserver.com](https://www.jsonrpcserver.com/). 46 | 47 | - Methods must now return a Result (Success or Error). 48 | - The dispatch function now returns a string. 49 | - Methods collection is now a simple dict, the Methods class has been removed. 50 | - Changed all classes (Request, Response, Methods, etc) to namedtuples. 51 | - Logging removed. User can log instead. 52 | - Config file removed. Configure with arguments to dispatch. 53 | - Removed "trim log values" and "convert camel case" options. 54 | - Removed the custom exceptions, replaced with one JsonRpcError exception. 55 | 56 | ## 4.2.0 (Nov 9, 2020) 57 | 58 | - Add ability to use custom serializer and deserializer ([#125](https://github.com/explodinglabs/jsonrpcserver/pull/125)) 59 | - Add ability to use custom method name ([#127](https://github.com/explodinglabs/jsonrpcserver/pull/127)) 60 | - Deny additional parameters in json-rpc request ([#128](https://github.com/explodinglabs/jsonrpcserver/pull/128)) 61 | 62 | Thanks to deptyped. 63 | 64 | ## 4.1.3 (May 2, 2020) 65 | 66 | - In the case of a method returning a non-serializable value, return a JSON-RPC 67 | error response. It was previously erroring server-side without responding to 68 | the client. (#119) 69 | - Fix for Python 3.8 - ensures the same exceptions will be raised in 3.8 and 70 | pre-3.8. (#122) 71 | 72 | ## 4.1.2 (Jan 9, 2020) 73 | 74 | - Fix the egg-info directory in package. 75 | 76 | ## 4.1.1 (Jan 8, 2020) 77 | 78 | - Fix file permission on all files. 79 | 80 | ## 4.1.0 (Jan 6, 2020) 81 | 82 | - Add InvalidParamsError exception, for input validation. Previously the 83 | advice was to `assert` on input values. However, AssertionError was too 84 | generic an exception. Instead, raise InvalidParamsError. Note `assert` will 85 | still work but will be removed in the next major release (5.0). 86 | - Add an ApiError exception; raise it to send an application defined error 87 | response. This covers the line in the JSON-RPC spec, "The remainder of the 88 | space is available for application defined errors." 89 | - A KeyError raised inside methods will no longer send a "method not found" 90 | response. 91 | - Uncaught exceptions raised inside methods will now be logged. We've been 92 | simply responding to the client with a Server Error. Now the traceback will 93 | also be logged server-side. 94 | - Fix a deprecation warning related to collections.abc. 95 | - Add py.typed to indicate this package supports typing. (PEP 561) 96 | 97 | Thanks to steinymity for his work on this release. 98 | 99 | ## 4.0.5 (Sep 10, 2019) 100 | 101 | - Include license in package. 102 | 103 | ## 4.0.4 (Jun 22, 2019) 104 | 105 | - Use faster method of jsonschema validation 106 | - Use inspect from stdlib, removing the need for funcsigs 107 | 108 | ## 4.0.3 (Jun 15, 2019) 109 | 110 | - Update dependencies to allow jsonschema version 3.x 111 | - Support Python 3.8 112 | 113 | ## 4.0.2 (Apr 13, 2019) 114 | 115 | - Fix to allow passing context when no parameters are passed. 116 | 117 | ## 4.0.1 (Dec 21, 2018) 118 | 119 | - Include exception in ExceptionResponse. Closes #74. 120 | 121 | ## 4.0.0 (Oct 14, 2018) 122 | 123 | _The 4.x releases will support Python 3.6+ only._ 124 | 125 | - Dispatch now works only with `Methods` object. No longer accepts a 126 | dictionary or list. 127 | - `dispatch` no longer requires a "methods" argument. If not passed, uses the 128 | global methods object. 129 | - Methods initialiser has a simpler api - Methods(func1, func2, ...) or 130 | Methods(name1=func1, name2=func2, ...). 131 | - No more jsonrpcserver exceptions. Calling code will _always_ get a valid 132 | JSON-RPC response from `dispatch`. The old `InvalidParamsError` is gone 133 | - instead do a regular `assert` on arguments. 134 | - `response.is_notification` renamed to `response.wanted`, which is the 135 | opposite of is_notification. This means the original request was not a 136 | notification, it had an id, and does expect a response. 137 | - Removed "respond to notification errors" option, which broke the 138 | specification. We still respond to invalid json/json-rpc requests, in which 139 | case it's not possible to know if the request is a notification. 140 | - Removed the "config" module. Added external config file, `.jsonrpcserverrc`. 141 | (alternatively configure with arguments to dispatch) 142 | - Removed the "six" dependency, no longer needed. 143 | - Configure logging Pythonically. 144 | - Add type hints 145 | - Move tests to pytest 146 | - Passing a context object to dispatch now sets it as the first positional 147 | argument to the method. `def fruits(ctx, color):` 148 | - Check params with regular asserts. 149 | 150 | ## 3.5.6 (Jun 28, 2018) 151 | - Add trim_log_values dispatch param. (#65) 152 | - Fix a missing import 153 | 154 | ## 3.5.5 (Jun 19, 2018) 155 | - Rewrite of dispatch(), adding parameters to configure the dispatch that were 156 | previously configured by modifying the `config` module. That module is now 157 | deprecated and will be removed in 4.0. 158 | 159 | ## 3.5.4 (Apr 30, 2018) 160 | - Refactoring 161 | 162 | ## 3.5.3 (Dec 19, 2017) 163 | - Allow requests to have any non-None id 164 | 165 | ## 3.5.2 (Sep 19, 2017) 166 | - Refactor for Request subclassing 167 | 168 | ## 3.5.1 (Aug 12, 2017) 169 | - Include context data in regular (synchronous) methods.dispatch 170 | 171 | ## 3.5.0 (Aug 12, 2017) 172 | - Pass some context data through dispatch to the methods. 173 | - Fix not calling notifications in batch requests. 174 | 175 | ## 3.4.3 (Jul 13, 2017) 176 | - Fix AttributeError on batch responses 177 | 178 | ## 3.4.3 (Jul 12, 2017) 179 | - Add `Response.is_notification` attribute 180 | 181 | ## 3.4.2 (Jun 9, 2017) 182 | - Fix `convert_camel_case` with array params 183 | 184 | ## 3.4.1 (Oct 4, 2016) 185 | - Disable logging in config 186 | - Performance improved 187 | - Fix async batch requests 188 | 189 | ## 3.4.0 (Sep 27, 2016) 190 | - Added asyncio support. (Python 3.5+) 191 | - Added a *methods* object to the jsonrpcserver module (so you can import 192 | jsonrpcserver.methods, rather than instantiating your own). 193 | - Added methods.dispatch(). 194 | 195 | ## 3.3.4 (Sep 22, 2016) 196 | - Fix Methods.serve_forever in python 2 (thanks @bplower) 197 | 198 | ## 3.3.3 (Sep 15, 2016) 199 | - Updated method of logging exception (thanks @bplower) 200 | 201 | ## 3.3.2 (Aug 19, 2016) 202 | - Pass Methods named args onto MutableMapping 203 | - Remove unused logger 204 | 205 | ## 3.3.1 (Aug 5, 2016) 206 | - Allow passing dict to Methods constructor 207 | 208 | ## 3.3.0 (Aug 5, 2016) 209 | - A basic HTTP server has been added. 210 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Beau Barker (beau at explodinglabs.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md CHANGELOG.md LICENSE 2 | include jsonrpcserver/py.typed jsonrpcserver/request-schema.json 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Jsonrpcserver Logo 3 |

4 | 5 |

6 | Process incoming JSON-RPC requests in Python 7 |

8 | 9 |

10 | PyPI 11 | Code Quality 12 | Coverage Status 13 | Downloads 14 | License 15 |

16 | 17 | https://github.com/user-attachments/assets/94fb4f04-a5f1-41ca-84dd-7e18b87990e0 18 | 19 | ## 🚀 Installation 20 | 21 | ```sh 22 | pip install jsonrpcserver 23 | ``` 24 | 25 | ## ⚒️ Usage 26 | 27 | ```python 28 | from jsonrpcserver import dispatch, method, Success 29 | 30 | @method 31 | def ping(): 32 | return Success("pong") 33 | 34 | response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') 35 | # => '{"jsonrpc": "2.0", "result": "pong", "id": 1}' 36 | ``` 37 | 38 | 👉 Full documentation is at [explodinglabs.com/jsonrpcserver](https://www.explodinglabs.com/jsonrpcserver/). 39 | 40 | ## ➡️ See Also 41 | 42 | - [jsonrpcclient](https://github.com/explodinglabs/jsonrpcclient) – Create JSON-RPC requests and parse responses in Python 43 | -------------------------------------------------------------------------------- /docs/async.md: -------------------------------------------------------------------------------- 1 | # Async 2 | 3 | Async dispatch is supported. 4 | 5 | ```python 6 | from jsonrpcserver import method, Success, async_dispatch 7 | 8 | @method 9 | async def ping() -> Result: 10 | return Success("pong") 11 | 12 | await async_dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') 13 | ``` 14 | 15 | Some reasons to use this: 16 | 17 | - Use it with an asynchronous protocol like sockets or message queues. 18 | - `await` long-running functions from your method. 19 | - Batch requests are dispatched concurrently. 20 | 21 | ## Notifications 22 | 23 | Notifications are requests without an `id`. We should not respond to 24 | notifications, so jsonrpcserver gives an empty string to signify there is *no 25 | response*. 26 | 27 | ```python 28 | >>> await async_dispatch('{"jsonrpc": "2.0", "method": "ping"}') 29 | '' 30 | ``` 31 | 32 | If the response is an empty string, don't send it. 33 | 34 | ```python 35 | if response := dispatch(request): 36 | send(response) 37 | ``` 38 | 39 | ```{note} 40 | A synchronous protocol like HTTP requires a response no matter what, so we can 41 | send back the empty string. However with async protocols, we have the choice of 42 | responding or not. 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/dispatch.md: -------------------------------------------------------------------------------- 1 | # Dispatch 2 | 3 | The `dispatch` function takes a JSON-RPC request, calls the appropriate method 4 | and gives a JSON-RPC response. 5 | 6 | ```python 7 | >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') 8 | '{"jsonrpc": "2.0", "result": "pong", "id": 1}' 9 | ``` 10 | 11 | [See how dispatch is used in different frameworks.](examples.md) 12 | 13 | ## Optional parameters 14 | 15 | ### methods 16 | 17 | This lets you specify a group of methods to dispatch to. It's an alternative to 18 | using the `@method` decorator. The value should be a dict mapping function 19 | names to functions. 20 | 21 | ```python 22 | def ping(): 23 | return Success("pong") 24 | 25 | dispatch(request, methods={"ping": ping}) 26 | ``` 27 | 28 | Default is `global_methods`, which is an internal dict populated by the 29 | `@method` decorator. 30 | 31 | ### context 32 | 33 | If specified, this will be the first argument to all methods. 34 | 35 | ```python 36 | @method 37 | def greet(context, name): 38 | return Success(context + " " + name) 39 | 40 | >>> dispatch('{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', context="Hello") 41 | '{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' 42 | ``` 43 | 44 | ### deserializer 45 | 46 | A function that parses the request string. Default is `json.loads`. 47 | 48 | ```python 49 | dispatch(request, deserializer=ujson.loads) 50 | ``` 51 | 52 | ### serializer 53 | 54 | A function that serializes the response string. Default is `json.dumps`. 55 | 56 | ```python 57 | dispatch(request, serializer=ujson.dumps) 58 | ``` 59 | 60 | ### validator 61 | 62 | A function that validates the request once the json has been parsed. The 63 | function should raise an exception (any exception) if the request doesn't match 64 | the JSON-RPC spec. Default is `default_validator` which validates the request 65 | against a schema. 66 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## aiohttp 4 | 5 | ```python 6 | from aiohttp import web 7 | 8 | from jsonrpcserver import Result, Success, async_dispatch, method 9 | 10 | 11 | @method 12 | async def ping() -> Result: 13 | """JSON-RPC method""" 14 | return Success("pong") 15 | 16 | 17 | async def handle(request: web.Request) -> web.Response: 18 | """Handle aiohttp request""" 19 | return web.Response( 20 | text=await async_dispatch(await request.text()), content_type="application/json" 21 | ) 22 | 23 | 24 | app = web.Application() 25 | app.router.add_post("/", handle) 26 | 27 | if __name__ == "__main__": 28 | web.run_app(app, port=5000) 29 | ``` 30 | 31 | See [blog post](https://explodinglabs.github.io/jsonrpc/aiohttp). 32 | 33 | ## Django 34 | 35 | Create a `views.py`: 36 | 37 | ```python 38 | from django.http import HttpRequest, HttpResponse # type: ignore 39 | from django.views.decorators.csrf import csrf_exempt # type: ignore 40 | 41 | from jsonrpcserver import Result, Success, dispatch, method 42 | 43 | 44 | @method 45 | def ping() -> Result: 46 | """JSON-RPC method""" 47 | return Success("pong") 48 | 49 | 50 | @csrf_exempt # type: ignore 51 | def jsonrpc(request: HttpRequest) -> HttpResponse: 52 | """Handle Django request""" 53 | return HttpResponse( 54 | dispatch(request.body.decode()), content_type="application/json" 55 | ) 56 | ``` 57 | 58 | See [blog post](https://explodinglabs.github.io/jsonrpc/django). 59 | 60 | ## FastAPI 61 | 62 | ```python 63 | import uvicorn 64 | from fastapi import FastAPI, Request, Response 65 | 66 | from jsonrpcserver import Result, Success, dispatch, method 67 | 68 | app = FastAPI() 69 | 70 | 71 | @method 72 | def ping() -> Result: 73 | """JSON-RPC method""" 74 | return Success("pong") 75 | 76 | 77 | @app.post("/") 78 | async def index(request: Request) -> Response: 79 | """Handle FastAPI request""" 80 | return Response(dispatch(await request.body())) 81 | 82 | 83 | if __name__ == "__main__": 84 | uvicorn.run(app, port=5000) 85 | ``` 86 | 87 | See [blog post](https://explodinglabs.github.io/jsonrpc/fastapi). 88 | 89 | ## Flask 90 | 91 | ```python 92 | from flask import Flask, Response, request 93 | 94 | from jsonrpcserver import Result, Success, dispatch, method 95 | 96 | app = Flask(__name__) 97 | 98 | 99 | @method 100 | def ping() -> Result: 101 | """JSON-RPC method""" 102 | return Success("pong") 103 | 104 | 105 | @app.route("/", methods=["POST"]) 106 | def index() -> Response: 107 | """Handle Flask request""" 108 | return Response( 109 | dispatch(request.get_data().decode()), content_type="application/json" 110 | ) 111 | 112 | 113 | if __name__ == "__main__": 114 | app.run() 115 | ``` 116 | 117 | See [blog post](https://explodinglabs.github.io/jsonrpc/flask). 118 | 119 | ## http.server 120 | 121 | Using Python's built-in 122 | [http.server](https://docs.python.org/3/library/http.server.html) module. 123 | 124 | ```python 125 | from http.server import BaseHTTPRequestHandler, HTTPServer 126 | 127 | from jsonrpcserver import Result, Success, dispatch, method 128 | 129 | 130 | @method 131 | def ping() -> Result: 132 | """JSON-RPC method""" 133 | return Success("pong") 134 | 135 | 136 | class TestHttpServer(BaseHTTPRequestHandler): 137 | """HTTPServer request handler""" 138 | 139 | def do_POST(self) -> None: # pylint: disable=invalid-name 140 | """POST handler""" 141 | # Process request 142 | request = self.rfile.read(int(self.headers["Content-Length"])).decode() 143 | response = dispatch(request) 144 | # Return response 145 | self.send_response(200) 146 | self.send_header("Content-type", "application/json") 147 | self.end_headers() 148 | self.wfile.write(response.encode()) 149 | 150 | 151 | if __name__ == "__main__": 152 | HTTPServer(("localhost", 5000), TestHttpServer).serve_forever() 153 | ``` 154 | 155 | See [blog post](https://explodinglabs.github.io/jsonrpc/httpserver). 156 | 157 | ## jsonrpcserver 158 | 159 | Using jsonrpcserver's built-in `serve` method. 160 | 161 | ```python 162 | from jsonrpcserver import Result, Success, method, serve 163 | 164 | 165 | @method 166 | def ping() -> Result: 167 | """JSON-RPC method""" 168 | return Success("pong") 169 | 170 | 171 | if __name__ == "__main__": 172 | serve() 173 | ``` 174 | 175 | ## Sanic 176 | 177 | ```python 178 | from sanic import Sanic 179 | from sanic.request import Request 180 | from sanic.response import HTTPResponse, json 181 | 182 | from jsonrpcserver import Result, Success, dispatch_to_serializable, method 183 | 184 | app = Sanic("JSON-RPC app") 185 | 186 | 187 | @method 188 | def ping() -> Result: 189 | """JSON-RPC method""" 190 | return Success("pong") 191 | 192 | 193 | @app.route("/", methods=["POST"]) 194 | async def test(request: Request) -> HTTPResponse: 195 | """Handle Sanic request""" 196 | return json(dispatch_to_serializable(request.body)) 197 | 198 | 199 | if __name__ == "__main__": 200 | app.run(port=5000) 201 | ``` 202 | 203 | See [blog post](https://explodinglabs.github.io/jsonrpc/sanic). 204 | 205 | ## Socket.IO 206 | 207 | ```python 208 | from flask import Flask, Request 209 | from flask_socketio import SocketIO, send # type: ignore 210 | 211 | from jsonrpcserver import Result, Success, dispatch, method 212 | 213 | app = Flask(__name__) 214 | socketio = SocketIO(app) 215 | 216 | 217 | @method 218 | def ping() -> Result: 219 | """JSON-RPC method""" 220 | return Success("pong") 221 | 222 | 223 | @socketio.on("message") # type: ignore 224 | def handle_message(request: Request) -> None: 225 | """Handle SocketIO request""" 226 | if response := dispatch(request): 227 | send(response, json=True) 228 | 229 | 230 | if __name__ == "__main__": 231 | socketio.run(app, port=5000) 232 | ``` 233 | 234 | See [blog post](https://explodinglabs.github.io/jsonrpc/flask-socketio). 235 | 236 | ## Tornado 237 | 238 | ```python 239 | from typing import Awaitable, Optional 240 | 241 | from tornado import ioloop, web 242 | 243 | from jsonrpcserver import Result, Success, async_dispatch, method 244 | 245 | 246 | @method 247 | async def ping() -> Result: 248 | """JSON-RPC method""" 249 | return Success("pong") 250 | 251 | 252 | class MainHandler(web.RequestHandler): 253 | """Handle Tornado request""" 254 | 255 | async def post(self) -> None: 256 | """Post""" 257 | request = self.request.body.decode() 258 | response = await async_dispatch(request) 259 | if response: 260 | self.write(response) 261 | 262 | def data_received(self, chunk: bytes) -> Optional[Awaitable[None]]: 263 | pass 264 | 265 | 266 | app = web.Application([(r"/", MainHandler)]) 267 | 268 | if __name__ == "__main__": 269 | app.listen(5000) 270 | ioloop.IOLoop.current().start() 271 | ``` 272 | 273 | See [blog post](https://explodinglabs.github.io/jsonrpc/tornado). 274 | 275 | ## Websockets 276 | 277 | ```python 278 | import asyncio 279 | 280 | from websockets.server import WebSocketServerProtocol, serve 281 | 282 | from jsonrpcserver import Result, Success, async_dispatch, method 283 | 284 | 285 | @method 286 | async def ping() -> Result: 287 | """JSON-RPC method""" 288 | return Success("pong") 289 | 290 | 291 | async def main(websocket: WebSocketServerProtocol, _: str) -> None: 292 | """Handle Websocket message""" 293 | if response := await async_dispatch(await websocket.recv()): 294 | await websocket.send(response) 295 | 296 | 297 | start_server = serve(main, "localhost", 5000) 298 | asyncio.get_event_loop().run_until_complete(start_server) 299 | asyncio.get_event_loop().run_forever() 300 | ``` 301 | 302 | See [blog post](https://explodinglabs.github.io/jsonrpc/websockets). 303 | 304 | ## Werkzeug 305 | 306 | ```python 307 | from werkzeug.serving import run_simple 308 | from werkzeug.wrappers import Request, Response 309 | 310 | from jsonrpcserver import Result, Success, dispatch, method 311 | 312 | 313 | @method 314 | def ping() -> Result: 315 | """JSON-RPC method""" 316 | return Success("pong") 317 | 318 | 319 | @Request.application 320 | def application(request: Request) -> Response: 321 | """Handle Werkzeug request""" 322 | return Response(dispatch(request.data.decode()), 200, mimetype="application/json") 323 | 324 | 325 | if __name__ == "__main__": 326 | run_simple("localhost", 5000, application) 327 | ``` 328 | 329 | See [blog post](https://explodinglabs.github.io/jsonrpc/werkzeug). 330 | 331 | ## ZeroMQ 332 | 333 | ```python 334 | import zmq 335 | 336 | from jsonrpcserver import Result, Success, dispatch, method 337 | 338 | socket = zmq.Context().socket(zmq.REP) 339 | 340 | 341 | @method 342 | def ping() -> Result: 343 | """JSON-RPC method""" 344 | return Success("pong") 345 | 346 | 347 | if __name__ == "__main__": 348 | socket.bind("tcp://*:5000") 349 | while True: 350 | request = socket.recv().decode() 351 | socket.send_string(dispatch(request)) 352 | ``` 353 | 354 | See [blog post](https://explodinglabs.github.io/jsonrpc/zeromq). 355 | 356 | ## ZeroMQ (async) 357 | 358 | ```python 359 | import asyncio 360 | 361 | import aiozmq # type: ignore 362 | import zmq 363 | 364 | from jsonrpcserver import Result, Success, async_dispatch, method 365 | 366 | 367 | @method 368 | async def ping() -> Result: 369 | """JSON-RPC method""" 370 | return Success("pong") 371 | 372 | 373 | async def main() -> None: 374 | """Handle AioZMQ request""" 375 | rep = await aiozmq.create_zmq_stream(zmq.REP, bind="tcp://*:5000") 376 | while True: 377 | request = (await rep.read())[0].decode() 378 | if response := (await async_dispatch(request)).encode(): 379 | rep.write((response,)) 380 | 381 | 382 | if __name__ == "__main__": 383 | asyncio.set_event_loop_policy(aiozmq.ZmqEventLoopPolicy()) 384 | asyncio.get_event_loop().run_until_complete(main()) 385 | ``` 386 | 387 | See [blog post](https://explodinglabs.github.io/jsonrpc/zeromq-async). 388 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## How to disable schema validation? 4 | 5 | Validating requests is costly - roughly 40% of dispatching time is spent on schema validation. 6 | If you know the incoming requests are valid, you can disable the validation for better 7 | performance. 8 | 9 | ```python 10 | dispatch(request, validator=lambda _: None) 11 | ``` 12 | 13 | ## Which HTTP status code to respond with? 14 | 15 | I suggest: 16 | 17 | ```python 18 | 200 if response else 204 19 | ``` 20 | 21 | If the request was a notification, `dispatch` will give you an empty string. So 22 | since there's no http body, use status code 204 - no content. 23 | 24 | ## How to rename a method 25 | 26 | Use `@method(name="new_name")`. 27 | 28 | Or use the dispatch function's [methods 29 | parameter](https://www.explodinglabs.com/jsonrpcserver/dispatch/#methods). 30 | 31 | ## How to get the response in other forms? 32 | 33 | Instead of `dispatch`, use: 34 | 35 | - `dispatch_to_serializable` to get the response as a dict. 36 | - `dispatch_to_response` to get the response as a namedtuple (either a 37 | `SuccessResponse` or `ErrorResponse`, these are defined in 38 | [response.py](https://github.com/explodinglabs/jsonrpcserver/blob/main/jsonrpcserver/response.py)). 39 | 40 | For these functions, if the request was a batch, you'll get a list of 41 | responses. If the request was a notification, you'll get `None`. 42 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodinglabs/jsonrpcserver/2d3d07658522f30bae7db3325051f18257fc7791/docs/images/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | --- 5 | 6 | 11 | 12 | ![jsonrpcserver](images/logo.png) 13 | 14 | _Process incoming JSON-RPC requests in Python._ 15 | 16 | ## Documentation 17 | 18 | - [Installation](installation.md) 19 | - [Methods](methods.md) 20 | - [Dispatch](dispatch.md) 21 | - [Async](async.md) 22 | - [Faq](faq.md) 23 | - [Examples](examples.md) 24 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - title 4 | - toc 5 | --- 6 | 7 | # Installation 8 | 9 | Create a `server.py`: 10 | 11 | ```python 12 | from jsonrpcserver import Success, method, serve 13 | 14 | @method 15 | def ping(): 16 | return Success("pong") 17 | 18 | if __name__ == "__main__": 19 | serve() 20 | ``` 21 | 22 | Start the server: 23 | 24 | ```sh 25 | $ pip install jsonrpcserver 26 | $ python server.py 27 | * Listening on port 5000 28 | ``` 29 | 30 | Test the server: 31 | 32 | ```sh 33 | $ curl -X POST http://localhost:5000 -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' 34 | {"jsonrpc": "2.0", "result": "pong", "id": 1} 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/methods.md: -------------------------------------------------------------------------------- 1 | # Methods 2 | 3 | Methods are functions that can be called by a JSON-RPC request. To write one, 4 | decorate a function with `@method`: 5 | 6 | ```python 7 | from jsonrpcserver import method, Result, Success, Error 8 | 9 | @method 10 | def ping() -> Result: 11 | return Success("pong") 12 | ``` 13 | 14 | If you don't need to respond with any value simply `return Success()`. 15 | 16 | ## Responses 17 | 18 | Methods return either `Success` or `Error`. These are the [JSON-RPC response 19 | objects](https://www.jsonrpc.org/specification#response_object) (excluding the 20 | `jsonrpc` and `id` parts). `Error` takes a code, message, and optionally 'data'. 21 | 22 | ```python 23 | @method 24 | def test() -> Result: 25 | return Error(1, "There was a problem") 26 | ``` 27 | 28 | ```{note} 29 | Alternatively, raise a `JsonRpcError`, which takes the same arguments as `Error`. 30 | ``` 31 | 32 | ## Parameters 33 | 34 | Methods can accept arguments. 35 | 36 | ```python 37 | @method 38 | def hello(name: str) -> Result: 39 | return Success("Hello " + name) 40 | ``` 41 | 42 | Testing it: 43 | 44 | ```sh 45 | $ curl -X POST http://localhost:5000 -d '{"jsonrpc": "2.0", "method": "hello", "params": ["Beau"], "id": 1}' 46 | {"jsonrpc": "2.0", "result": "Hello Beau", "id": 1} 47 | ``` 48 | 49 | ## Invalid params 50 | 51 | A common error response is *invalid params*. 52 | The JSON-RPC error code for this is **-32602**. A shortcut, *InvalidParams*, is 53 | included so you don't need to remember that. 54 | 55 | ```python 56 | from jsonrpcserver import method, Result, InvalidParams, Success, dispatch 57 | 58 | @method 59 | def within_range(num: int) -> Result: 60 | if num not in range(1, 5): 61 | return InvalidParams("Value must be 1-5") 62 | return Success() 63 | ``` 64 | 65 | This is the same as saying 66 | ```python 67 | return Error(-32602, "Invalid params", "Value must be 1-5") 68 | ``` 69 | -------------------------------------------------------------------------------- /examples/aiohttp_server.py: -------------------------------------------------------------------------------- 1 | """AioHTTP server""" 2 | from aiohttp import web 3 | 4 | from jsonrpcserver import Result, Success, async_dispatch, method 5 | 6 | 7 | @method 8 | async def ping() -> Result: 9 | """JSON-RPC method""" 10 | return Success("pong") 11 | 12 | 13 | async def handle(request: web.Request) -> web.Response: 14 | """Handle aiohttp request""" 15 | return web.Response( 16 | text=await async_dispatch(await request.text()), content_type="application/json" 17 | ) 18 | 19 | 20 | app = web.Application() 21 | app.router.add_post("/", handle) 22 | 23 | if __name__ == "__main__": 24 | web.run_app(app, port=5000) 25 | -------------------------------------------------------------------------------- /examples/aiozmq_server.py: -------------------------------------------------------------------------------- 1 | """AioZMQ server""" 2 | import asyncio 3 | 4 | import aiozmq # type: ignore 5 | import zmq 6 | 7 | from jsonrpcserver import Result, Success, async_dispatch, method 8 | 9 | 10 | @method 11 | async def ping() -> Result: 12 | """JSON-RPC method""" 13 | return Success("pong") 14 | 15 | 16 | async def main() -> None: 17 | """Handle AioZMQ request""" 18 | rep = await aiozmq.create_zmq_stream(zmq.REP, bind="tcp://*:5000") 19 | while True: 20 | request = (await rep.read())[0].decode() 21 | if response := (await async_dispatch(request)).encode(): 22 | rep.write((response,)) 23 | 24 | 25 | if __name__ == "__main__": 26 | asyncio.set_event_loop_policy(aiozmq.ZmqEventLoopPolicy()) 27 | asyncio.get_event_loop().run_until_complete(main()) 28 | -------------------------------------------------------------------------------- /examples/asyncio_server.py: -------------------------------------------------------------------------------- 1 | """Demonstrates processing a batch of 100 requests asynchronously with asyncio.""" 2 | import asyncio 3 | import json 4 | 5 | from jsonrpcserver import Result, Success, async_dispatch, method 6 | 7 | 8 | @method 9 | async def sleep_() -> Result: 10 | """JSON-RPC method""" 11 | await asyncio.sleep(1) 12 | return Success() 13 | 14 | 15 | async def handle(req: str) -> None: 16 | """Handle asyncio event""" 17 | print(await async_dispatch(req)) 18 | 19 | 20 | if __name__ == "__main__": 21 | request = json.dumps( 22 | [{"jsonrpc": "2.0", "method": "sleep_", "id": 1} for _ in range(100)] 23 | ) 24 | asyncio.get_event_loop().run_until_complete(handle(request)) 25 | -------------------------------------------------------------------------------- /examples/django_server.py: -------------------------------------------------------------------------------- 1 | """Django server""" 2 | from django.http import HttpRequest, HttpResponse # type: ignore 3 | from django.views.decorators.csrf import csrf_exempt # type: ignore 4 | 5 | from jsonrpcserver import Result, Success, dispatch, method 6 | 7 | 8 | @method 9 | def ping() -> Result: 10 | """JSON-RPC method""" 11 | return Success("pong") 12 | 13 | 14 | @csrf_exempt # type: ignore 15 | def jsonrpc(request: HttpRequest) -> HttpResponse: 16 | """Handle Django request""" 17 | return HttpResponse( 18 | dispatch(request.body.decode()), content_type="application/json" 19 | ) 20 | -------------------------------------------------------------------------------- /examples/fastapi_server.py: -------------------------------------------------------------------------------- 1 | """FastAPI server""" 2 | import uvicorn 3 | from fastapi import FastAPI, Request, Response 4 | 5 | from jsonrpcserver import Result, Success, dispatch, method 6 | 7 | app = FastAPI() 8 | 9 | 10 | @method 11 | def ping() -> Result: 12 | """JSON-RPC method""" 13 | return Success("pong") 14 | 15 | 16 | @app.post("/") 17 | async def index(request: Request) -> Response: 18 | """Handle FastAPI request""" 19 | return Response(dispatch(await request.body())) 20 | 21 | 22 | if __name__ == "__main__": 23 | uvicorn.run(app, port=5000) 24 | -------------------------------------------------------------------------------- /examples/flask_server.py: -------------------------------------------------------------------------------- 1 | """Flask server""" 2 | from flask import Flask, Response, request 3 | 4 | from jsonrpcserver import Result, Success, dispatch, method 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | @method 10 | def ping() -> Result: 11 | """JSON-RPC method""" 12 | return Success("pong") 13 | 14 | 15 | @app.route("/", methods=["POST"]) 16 | def index() -> Response: 17 | """Handle Flask request""" 18 | return Response( 19 | dispatch(request.get_data().decode()), content_type="application/json" 20 | ) 21 | 22 | 23 | if __name__ == "__main__": 24 | app.run() 25 | -------------------------------------------------------------------------------- /examples/http_server.py: -------------------------------------------------------------------------------- 1 | """HTTPServer server 2 | 3 | Demonstrates using Python's builtin http.server module to serve JSON-RPC. 4 | """ 5 | from http.server import BaseHTTPRequestHandler, HTTPServer 6 | 7 | from jsonrpcserver import Result, Success, dispatch, method 8 | 9 | 10 | @method 11 | def ping() -> Result: 12 | """JSON-RPC method""" 13 | return Success("pong") 14 | 15 | 16 | class TestHttpServer(BaseHTTPRequestHandler): 17 | """HTTPServer request handler""" 18 | 19 | def do_POST(self) -> None: # pylint: disable=invalid-name 20 | """POST handler""" 21 | # Process request 22 | request = self.rfile.read(int(self.headers["Content-Length"])).decode() 23 | response = dispatch(request) 24 | # Return response 25 | self.send_response(200) 26 | self.send_header("Content-type", "application/json") 27 | self.end_headers() 28 | self.wfile.write(response.encode()) 29 | 30 | 31 | if __name__ == "__main__": 32 | HTTPServer(("localhost", 5000), TestHttpServer).serve_forever() 33 | -------------------------------------------------------------------------------- /examples/jsonrpcserver_server.py: -------------------------------------------------------------------------------- 1 | """Jsonrpcserver server. 2 | 3 | Uses jsonrpcserver's built-in "serve" function. 4 | """ 5 | from jsonrpcserver import Result, Success, method, serve 6 | 7 | 8 | @method 9 | def ping() -> Result: 10 | """JSON-RPC method""" 11 | return Success("pong") 12 | 13 | 14 | if __name__ == "__main__": 15 | serve() 16 | -------------------------------------------------------------------------------- /examples/sanic_server.py: -------------------------------------------------------------------------------- 1 | """Sanic server""" 2 | from sanic import Sanic 3 | from sanic.request import Request 4 | from sanic.response import HTTPResponse, json 5 | 6 | from jsonrpcserver import Result, Success, dispatch_to_serializable, method 7 | 8 | app = Sanic("JSON-RPC app") 9 | 10 | 11 | @method 12 | def ping() -> Result: 13 | """JSON-RPC method""" 14 | return Success("pong") 15 | 16 | 17 | @app.route("/", methods=["POST"]) 18 | async def test(request: Request) -> HTTPResponse: 19 | """Handle Sanic request""" 20 | return json(dispatch_to_serializable(request.body)) 21 | 22 | 23 | if __name__ == "__main__": 24 | app.run(port=5000) 25 | -------------------------------------------------------------------------------- /examples/socketio_server.py: -------------------------------------------------------------------------------- 1 | """SocketIO server""" 2 | from flask import Flask, Request 3 | from flask_socketio import SocketIO, send # type: ignore 4 | 5 | from jsonrpcserver import Result, Success, dispatch, method 6 | 7 | app = Flask(__name__) 8 | socketio = SocketIO(app) 9 | 10 | 11 | @method 12 | def ping() -> Result: 13 | """JSON-RPC method""" 14 | return Success("pong") 15 | 16 | 17 | @socketio.on("message") # type: ignore 18 | def handle_message(request: Request) -> None: 19 | """Handle SocketIO request""" 20 | if response := dispatch(request): 21 | send(response, json=True) 22 | 23 | 24 | if __name__ == "__main__": 25 | socketio.run(app, port=5000) 26 | -------------------------------------------------------------------------------- /examples/tornado_server.py: -------------------------------------------------------------------------------- 1 | """Tornado server""" 2 | from typing import Awaitable, Optional 3 | 4 | from tornado import ioloop, web 5 | 6 | from jsonrpcserver import Result, Success, async_dispatch, method 7 | 8 | 9 | @method 10 | async def ping() -> Result: 11 | """JSON-RPC method""" 12 | return Success("pong") 13 | 14 | 15 | class MainHandler(web.RequestHandler): 16 | """Handle Tornado request""" 17 | 18 | async def post(self) -> None: 19 | """Post""" 20 | request = self.request.body.decode() 21 | response = await async_dispatch(request) 22 | if response: 23 | self.write(response) 24 | 25 | def data_received(self, chunk: bytes) -> Optional[Awaitable[None]]: 26 | pass 27 | 28 | 29 | app = web.Application([(r"/", MainHandler)]) 30 | 31 | if __name__ == "__main__": 32 | app.listen(5000) 33 | ioloop.IOLoop.current().start() 34 | -------------------------------------------------------------------------------- /examples/websockets_server.py: -------------------------------------------------------------------------------- 1 | """Websockets server""" 2 | import asyncio 3 | 4 | from websockets.server import WebSocketServerProtocol, serve 5 | 6 | from jsonrpcserver import Result, Success, async_dispatch, method 7 | 8 | 9 | @method 10 | async def ping() -> Result: 11 | """JSON-RPC method""" 12 | return Success("pong") 13 | 14 | 15 | async def main(websocket: WebSocketServerProtocol, _: str) -> None: 16 | """Handle Websocket message""" 17 | if response := await async_dispatch(await websocket.recv()): 18 | await websocket.send(response) 19 | 20 | 21 | start_server = serve(main, "localhost", 5000) 22 | asyncio.get_event_loop().run_until_complete(start_server) 23 | asyncio.get_event_loop().run_forever() 24 | -------------------------------------------------------------------------------- /examples/werkzeug_server.py: -------------------------------------------------------------------------------- 1 | """Werkzeug server""" 2 | from werkzeug.serving import run_simple 3 | from werkzeug.wrappers import Request, Response 4 | 5 | from jsonrpcserver import Result, Success, dispatch, method 6 | 7 | 8 | @method 9 | def ping() -> Result: 10 | """JSON-RPC method""" 11 | return Success("pong") 12 | 13 | 14 | @Request.application 15 | def application(request: Request) -> Response: 16 | """Handle Werkzeug request""" 17 | return Response(dispatch(request.data.decode()), 200, mimetype="application/json") 18 | 19 | 20 | if __name__ == "__main__": 21 | run_simple("localhost", 5000, application) 22 | -------------------------------------------------------------------------------- /examples/zeromq_server.py: -------------------------------------------------------------------------------- 1 | """ZeroMQ server""" 2 | import zmq 3 | 4 | from jsonrpcserver import Result, Success, dispatch, method 5 | 6 | socket = zmq.Context().socket(zmq.REP) 7 | 8 | 9 | @method 10 | def ping() -> Result: 11 | """JSON-RPC method""" 12 | return Success("pong") 13 | 14 | 15 | if __name__ == "__main__": 16 | socket.bind("tcp://*:5000") 17 | while True: 18 | request = socket.recv().decode() 19 | socket.send_string(dispatch(request)) 20 | -------------------------------------------------------------------------------- /jsonrpcserver/__init__.py: -------------------------------------------------------------------------------- 1 | """Use __all__ so mypy considers these re-exported.""" 2 | __all__ = [ 3 | "Error", 4 | "InvalidParams", 5 | "JsonRpcError", 6 | "Result", 7 | "Success", 8 | "async_dispatch", 9 | "async_dispatch_to_response", 10 | "async_dispatch_to_serializable", 11 | "dispatch", 12 | "dispatch_to_response", 13 | "dispatch_to_serializable", 14 | "method", 15 | "serve", 16 | ] 17 | 18 | 19 | from .async_main import ( 20 | dispatch as async_dispatch, 21 | ) 22 | from .async_main import ( 23 | dispatch_to_response as async_dispatch_to_response, 24 | ) 25 | from .async_main import ( 26 | dispatch_to_serializable as async_dispatch_to_serializable, 27 | ) 28 | from .exceptions import JsonRpcError 29 | from .main import dispatch, dispatch_to_response, dispatch_to_serializable 30 | from .methods import method 31 | from .result import Error, InvalidParams, Result, Success 32 | from .server import serve 33 | -------------------------------------------------------------------------------- /jsonrpcserver/async_dispatcher.py: -------------------------------------------------------------------------------- 1 | """Async version of dispatcher.py""" 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | from itertools import starmap 6 | from typing import Any, Callable, Iterable, Tuple, Union 7 | 8 | from oslash.either import Left # type: ignore 9 | 10 | from .dispatcher import ( 11 | Deserialized, 12 | create_request, 13 | deserialize_request, 14 | extract_args, 15 | extract_kwargs, 16 | extract_list, 17 | get_method, 18 | not_notification, 19 | to_response, 20 | validate_args, 21 | validate_request, 22 | validate_result, 23 | ) 24 | from .exceptions import JsonRpcError 25 | from .methods import Method, Methods 26 | from .request import Request 27 | from .response import Response, ServerErrorResponse 28 | from .result import ErrorResult, InternalErrorResult, Result 29 | from .utils import make_list 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | # pylint: disable=missing-function-docstring,duplicate-code 34 | 35 | 36 | async def call(request: Request, context: Any, method: Method) -> Result: 37 | try: 38 | result = await method( 39 | *extract_args(request, context), **extract_kwargs(request) 40 | ) 41 | validate_result(result) 42 | except JsonRpcError as exc: 43 | return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) 44 | except Exception as exc: # pylint: disable=broad-except 45 | # Other error inside method - Internal error 46 | logger.exception(exc) 47 | return Left(InternalErrorResult(str(exc))) 48 | return result 49 | 50 | 51 | async def dispatch_request( 52 | methods: Methods, context: Any, request: Request 53 | ) -> Tuple[Request, Result]: 54 | method = get_method(methods, request.method).bind( 55 | partial(validate_args, request, context) 56 | ) 57 | return ( 58 | request, 59 | method 60 | if isinstance(method, Left) 61 | else await call( 62 | request, context, method._value # pylint: disable=protected-access 63 | ), 64 | ) 65 | 66 | 67 | async def dispatch_deserialized( 68 | methods: Methods, 69 | context: Any, 70 | post_process: Callable[[Response], Iterable[Any]], 71 | deserialized: Deserialized, 72 | ) -> Union[Response, Iterable[Response], None]: 73 | results = await asyncio.gather( 74 | *( 75 | dispatch_request(methods, context, r) 76 | for r in map(create_request, make_list(deserialized)) 77 | ) 78 | ) 79 | return extract_list( 80 | isinstance(deserialized, list), 81 | map( 82 | post_process, 83 | starmap(to_response, filter(not_notification, results)), 84 | ), 85 | ) 86 | 87 | 88 | async def dispatch_to_response_pure( 89 | *, 90 | deserializer: Callable[[str], Deserialized], 91 | validator: Callable[[Deserialized], Deserialized], 92 | methods: Methods, 93 | context: Any, 94 | post_process: Callable[[Response], Iterable[Any]], 95 | request: str, 96 | ) -> Union[Response, Iterable[Response], None]: 97 | try: 98 | result = deserialize_request(deserializer, request).bind( 99 | partial(validate_request, validator) 100 | ) 101 | return ( 102 | post_process(result) 103 | if isinstance(result, Left) 104 | else await dispatch_deserialized( 105 | methods, 106 | context, 107 | post_process, 108 | result._value, # pylint: disable=protected-access 109 | ) 110 | ) 111 | except Exception as exc: # pylint: disable=broad-except 112 | logger.exception(exc) 113 | return post_process(Left(ServerErrorResponse(str(exc), None))) 114 | -------------------------------------------------------------------------------- /jsonrpcserver/async_main.py: -------------------------------------------------------------------------------- 1 | """Async version of main.py. The public async functions.""" 2 | import json 3 | from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast 4 | 5 | from .async_dispatcher import dispatch_to_response_pure 6 | from .dispatcher import Deserialized 7 | from .main import default_deserializer, default_validator 8 | from .methods import Methods, global_methods 9 | from .response import Response, to_serializable 10 | from .sentinels import NOCONTEXT 11 | from .utils import identity 12 | 13 | # pylint: disable=missing-function-docstring,duplicate-code 14 | 15 | 16 | async def dispatch_to_response( 17 | request: str, 18 | methods: Optional[Methods] = None, 19 | *, 20 | context: Any = NOCONTEXT, 21 | deserializer: Callable[[str], Deserialized] = default_deserializer, 22 | validator: Callable[[Deserialized], Deserialized] = default_validator, 23 | post_process: Callable[[Response], Any] = identity, 24 | ) -> Union[Response, Iterable[Response], None]: 25 | return await dispatch_to_response_pure( 26 | deserializer=deserializer, 27 | validator=validator, 28 | post_process=post_process, 29 | context=context, 30 | methods=global_methods if methods is None else methods, 31 | request=request, 32 | ) 33 | 34 | 35 | async def dispatch_to_serializable( 36 | *args: Any, **kwargs: Any 37 | ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: 38 | return cast( 39 | Union[Dict[str, Any], List[Dict[str, Any]], None], 40 | await dispatch_to_response(*args, post_process=to_serializable, **kwargs), 41 | ) 42 | 43 | 44 | async def dispatch_to_json( 45 | *args: Any, 46 | serializer: Callable[ 47 | [Union[Dict[str, Any], List[Dict[str, Any]], None]], str 48 | ] = json.dumps, 49 | **kwargs: Any, 50 | ) -> str: 51 | response = await dispatch_to_serializable(*args, **kwargs) 52 | return "" if response is None else serializer(response) 53 | 54 | 55 | dispatch = dispatch_to_json 56 | -------------------------------------------------------------------------------- /jsonrpcserver/codes.py: -------------------------------------------------------------------------------- 1 | """JSONRPC error codes from http://www.jsonrpc.org/specification#error_object""" 2 | 3 | ERROR_PARSE_ERROR = -32700 4 | ERROR_INVALID_REQUEST = -32600 5 | ERROR_METHOD_NOT_FOUND = -32601 6 | ERROR_INVALID_PARAMS = -32602 7 | ERROR_INTERNAL_ERROR = -32603 8 | ERROR_SERVER_ERROR = -32000 9 | -------------------------------------------------------------------------------- /jsonrpcserver/dispatcher.py: -------------------------------------------------------------------------------- 1 | """Dispatcher - does the hard work of this library: parses, validates and dispatches 2 | requests, providing responses. 3 | """ 4 | 5 | # pylint: disable=protected-access 6 | import logging 7 | from functools import partial 8 | from inspect import signature 9 | from itertools import starmap 10 | from typing import Any, Callable, Dict, Iterable, List, Tuple, Union 11 | 12 | from oslash.either import Either, Left, Right # type: ignore 13 | 14 | from .exceptions import JsonRpcError 15 | from .methods import Method, Methods 16 | from .request import Request 17 | from .response import ( 18 | ErrorResponse, 19 | InvalidRequestResponse, 20 | ParseErrorResponse, 21 | Response, 22 | ServerErrorResponse, 23 | SuccessResponse, 24 | ) 25 | from .result import ( 26 | ErrorResult, 27 | InternalErrorResult, 28 | InvalidParamsResult, 29 | MethodNotFoundResult, 30 | Result, 31 | SuccessResult, 32 | ) 33 | from .sentinels import NOCONTEXT, NOID 34 | from .utils import compose, make_list 35 | 36 | Deserialized = Union[Dict[str, Any], List[Dict[str, Any]]] 37 | 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | def extract_list( 42 | is_batch: bool, responses: Iterable[Response] 43 | ) -> Union[Response, List[Response], None]: 44 | """This is the inverse of make_list. Here we extract a response back out of the list 45 | if it wasn't a batch request originally. Also applies a JSON-RPC rule: we do not 46 | respond to batches of notifications. 47 | 48 | Args: 49 | is_batch: True if the original request was a batch. 50 | responses: Iterable of responses. 51 | 52 | Returns: A single response, a batch of responses, or None (returns None to a 53 | notification or batch of notifications, to indicate we should not respond). 54 | """ 55 | # Need to materialize the iterable here to determine if it's empty. At least we're 56 | # at the end of processing (also need a list, not a generator, to serialize a batch 57 | # response with json.dumps). 58 | response_list = list(responses) 59 | # Responses have been removed, so in the case of either a single notification or a 60 | # batch of only notifications, return None 61 | if len(response_list) == 0: 62 | return None 63 | # For batches containing at least one non-notification, return the list 64 | if is_batch: 65 | return response_list 66 | # For single requests, extract it back from the list (there will be only one). 67 | return response_list[0] 68 | 69 | 70 | def to_response(request: Request, result: Result) -> Response: 71 | """Maps a Request plus a Result to a Response. A Response is just a Result plus the 72 | id from the original Request. 73 | 74 | Raises: AssertionError if the request is a notification. Notifications can't be 75 | responded to. If a notification is given and AssertionError is raised, we should 76 | respond with Server Error, because notifications should have been removed by 77 | this stage. 78 | 79 | Returns: A Response. 80 | """ 81 | assert request.id is not NOID 82 | return ( 83 | Left(ErrorResponse(**result._error._asdict(), id=request.id)) 84 | if isinstance(result, Left) 85 | else Right(SuccessResponse(**result._value._asdict(), id=request.id)) 86 | ) 87 | 88 | 89 | def extract_args(request: Request, context: Any) -> List[Any]: 90 | """Extracts the positional arguments from the request. 91 | 92 | If a context object is given, it's added as the first argument. 93 | 94 | Returns: A list containing the positional arguments. 95 | """ 96 | params = request.params if isinstance(request.params, list) else [] 97 | return [context] + params if context is not NOCONTEXT else params 98 | 99 | 100 | def extract_kwargs(request: Request) -> Dict[str, Any]: 101 | """Extracts the keyword arguments from the reqeust. 102 | 103 | Returns: A dict containing the keyword arguments. 104 | """ 105 | return request.params if isinstance(request.params, dict) else {} 106 | 107 | 108 | def validate_result(result: Result) -> None: 109 | """Validate the return value from a method. 110 | 111 | Raises an AssertionError if the result returned from a method is invalid. 112 | 113 | Returns: None 114 | """ 115 | assert (isinstance(result, Left) and isinstance(result._error, ErrorResult)) or ( 116 | isinstance(result, Right) and isinstance(result._value, SuccessResult) 117 | ), f"The method did not return a valid Result (returned {result!r})" 118 | 119 | 120 | def call(request: Request, context: Any, method: Method) -> Result: 121 | """Call the method. 122 | 123 | Handles any exceptions raised in the method, being sure to return an Error response. 124 | 125 | Returns: A Result. 126 | """ 127 | try: 128 | result = method(*extract_args(request, context), **extract_kwargs(request)) 129 | # validate_result raises AssertionError if the return value is not a valid 130 | # Result, which should respond with Internal Error because its a problem in the 131 | # method. 132 | validate_result(result) 133 | # Raising JsonRpcError inside the method is an alternative way of returning an error 134 | # response. 135 | except JsonRpcError as exc: 136 | return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) 137 | # Any other uncaught exception inside method - internal error. 138 | except Exception as exc: # pylint: disable=broad-except 139 | logger.exception(exc) 140 | return Left(InternalErrorResult(str(exc))) 141 | return result 142 | 143 | 144 | def validate_args( 145 | request: Request, context: Any, func: Method 146 | ) -> Either[ErrorResult, Method]: 147 | """Ensure the method can be called with the arguments given. 148 | 149 | Returns: Either the function to be called, or an Invalid Params error result. 150 | """ 151 | try: 152 | signature(func).bind(*extract_args(request, context), **extract_kwargs(request)) 153 | except TypeError as exc: 154 | return Left(InvalidParamsResult(str(exc))) 155 | return Right(func) 156 | 157 | 158 | def get_method(methods: Methods, method_name: str) -> Either[ErrorResult, Method]: 159 | """Get the requested method from the methods dict. 160 | 161 | Returns: Either the function to be called, or a Method Not Found result. 162 | """ 163 | try: 164 | return Right(methods[method_name]) 165 | except KeyError: 166 | return Left(MethodNotFoundResult(method_name)) 167 | 168 | 169 | def dispatch_request( 170 | methods: Methods, context: Any, request: Request 171 | ) -> Tuple[Request, Result]: 172 | """Get the method, validates the arguments and calls the method. 173 | 174 | Returns: A tuple containing the Result of the method, along with the original 175 | Request. We need the ids from the original request to remove notifications 176 | before responding, and create a Response. 177 | """ 178 | return ( 179 | request, 180 | get_method(methods, request.method) 181 | .bind(partial(validate_args, request, context)) 182 | .bind(partial(call, request, context)), 183 | ) 184 | 185 | 186 | def create_request(request: Dict[str, Any]) -> Request: 187 | """Create a Request namedtuple from a dict.""" 188 | return Request( 189 | request["method"], request.get("params", []), request.get("id", NOID) 190 | ) 191 | 192 | 193 | def not_notification(request_result: Any) -> bool: 194 | """True if the request was not a notification. 195 | 196 | Used to filter out notifications from the list of responses. 197 | """ 198 | return request_result[0].id is not NOID 199 | 200 | 201 | def dispatch_deserialized( 202 | methods: Methods, 203 | context: Any, 204 | post_process: Callable[[Response], Iterable[Any]], 205 | deserialized: Deserialized, 206 | ) -> Union[Response, List[Response], None]: 207 | """This is simply continuing the pipeline from dispatch_to_response_pure. It exists 208 | only to be an abstraction, otherwise that function is doing too much. It continues 209 | on from the request string having been parsed and validated. 210 | 211 | Returns: A Response, a list of Responses, or None. If post_process is passed, it's 212 | applied to the Response(s). 213 | """ 214 | results = map( 215 | compose(partial(dispatch_request, methods, context), create_request), 216 | make_list(deserialized), 217 | ) 218 | responses = starmap(to_response, filter(not_notification, results)) 219 | return extract_list(isinstance(deserialized, list), map(post_process, responses)) 220 | 221 | 222 | def validate_request( 223 | validator: Callable[[Deserialized], Deserialized], request: Deserialized 224 | ) -> Either[ErrorResponse, Deserialized]: 225 | """Validate the request against a JSON-RPC schema. 226 | 227 | Ensures the parsed request is valid JSON-RPC. 228 | 229 | Returns: Either the same request passed in or an Invalid request response. 230 | """ 231 | try: 232 | validator(request) 233 | # Since the validator is unknown, the specific exception that will be raised is also 234 | # unknown. Any exception raised we assume the request is invalid and return an 235 | # "invalid request" response. 236 | except Exception: # pylint: disable=broad-except 237 | return Left(InvalidRequestResponse("The request failed schema validation")) 238 | return Right(request) 239 | 240 | 241 | def deserialize_request( 242 | deserializer: Callable[[str], Deserialized], request: str 243 | ) -> Either[ErrorResponse, Deserialized]: 244 | """Parse the JSON request string. 245 | 246 | Returns: Either the deserialized request or a "Parse Error" response. 247 | """ 248 | try: 249 | return Right(deserializer(request)) 250 | # Since the deserializer is unknown, the specific exception that will be raised is 251 | # also unknown. Any exception raised we assume the request is invalid, return a 252 | # parse error response. 253 | except Exception as exc: # pylint: disable=broad-except 254 | return Left(ParseErrorResponse(str(exc))) 255 | 256 | 257 | def dispatch_to_response_pure( 258 | *, 259 | deserializer: Callable[[str], Deserialized], 260 | validator: Callable[[Deserialized], Deserialized], 261 | methods: Methods, 262 | context: Any, 263 | post_process: Callable[[Response], Iterable[Any]], 264 | request: str, 265 | ) -> Union[Response, List[Response], None]: 266 | """A function from JSON-RPC request string to Response namedtuple(s), (yet to be 267 | serialized to json). 268 | 269 | Returns: A single Response, a list of Responses, or None. None is given for 270 | notifications or batches of notifications, to indicate that we should not 271 | respond. 272 | """ 273 | try: 274 | result = deserialize_request(deserializer, request).bind( 275 | partial(validate_request, validator) 276 | ) 277 | return ( 278 | post_process(result) 279 | if isinstance(result, Left) 280 | else dispatch_deserialized(methods, context, post_process, result._value) 281 | ) 282 | except Exception as exc: # pylint: disable=broad-except 283 | # There was an error with the jsonrpcserver library. 284 | logger.exception(exc) 285 | return post_process(Left(ServerErrorResponse(str(exc), None))) 286 | -------------------------------------------------------------------------------- /jsonrpcserver/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions""" 2 | from typing import Any 3 | 4 | from .sentinels import NODATA 5 | 6 | 7 | class JsonRpcError(Exception): 8 | """A JsonRpcError exception can be raised from inside a method, as an alternate way 9 | to return an error response. See 10 | https://github.com/explodinglabs/jsonrpcserver/discussions/158 11 | """ 12 | 13 | def __init__(self, code: int, message: str, data: Any = NODATA): 14 | self.code, self.message, self.data = (code, message, data) 15 | -------------------------------------------------------------------------------- /jsonrpcserver/main.py: -------------------------------------------------------------------------------- 1 | """The public functions. 2 | 3 | These three public functions all perform the same function of dispatching a JSON-RPC 4 | request, but they each give a different return value. 5 | 6 | - dispatch_to_responses: Returns Response(s) (or None for notifications). 7 | - dispatch_to_serializable: Returns a Python dict or list of dicts (or None for 8 | notifications). 9 | - dispatch_to_json/dispatch: Returns a JSON-RPC response string (or an empty string for 10 | notifications). 11 | """ 12 | import json 13 | from importlib.resources import read_text 14 | from typing import Any, Callable, Dict, List, Optional, Union, cast 15 | 16 | from jsonschema.validators import validator_for # type: ignore 17 | 18 | from .dispatcher import Deserialized, dispatch_to_response_pure 19 | from .methods import Methods, global_methods 20 | from .response import Response, to_dict 21 | from .sentinels import NOCONTEXT 22 | from .utils import identity 23 | 24 | default_deserializer = json.loads 25 | 26 | # Prepare the jsonschema validator. This is global so it loads only once, not every 27 | # time dispatch is called. 28 | schema = json.loads(read_text(__package__, "request-schema.json")) 29 | klass = validator_for(schema) 30 | klass.check_schema(schema) 31 | default_validator = klass(schema).validate 32 | 33 | 34 | def dispatch_to_response( 35 | request: str, 36 | methods: Optional[Methods] = None, 37 | *, 38 | context: Any = NOCONTEXT, 39 | deserializer: Callable[[str], Deserialized] = json.loads, 40 | validator: Callable[[Deserialized], Deserialized] = default_validator, 41 | post_process: Callable[[Response], Any] = identity, 42 | ) -> Union[Response, List[Response], None]: 43 | """Takes a JSON-RPC request string and dispatches it to method(s), giving Response 44 | namedtuple(s) or None. 45 | 46 | This is a public wrapper around dispatch_to_response_pure, adding globals and 47 | default values to be nicer for end users. 48 | 49 | Args: 50 | request: The JSON-RPC request string. 51 | methods: Dictionary of methods that can be called - mapping of function names to 52 | functions. If not passed, uses the internal global_methods dict which is 53 | populated with the @method decorator. 54 | context: If given, will be passed as the first argument to methods. 55 | deserializer: Function that deserializes the request string. 56 | validator: Function that validates the JSON-RPC request. The function should 57 | raise an exception if the request is invalid. To disable validation, pass 58 | lambda _: None. 59 | post_process: Function that will be applied to Responses. 60 | 61 | Returns: 62 | A Response, list of Responses or None. 63 | 64 | Examples: 65 | >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') 66 | '{"jsonrpc": "2.0", "result": "pong", "id": 1}' 67 | """ 68 | return dispatch_to_response_pure( 69 | deserializer=deserializer, 70 | validator=validator, 71 | post_process=post_process, 72 | context=context, 73 | methods=global_methods if methods is None else methods, 74 | request=request, 75 | ) 76 | 77 | 78 | def dispatch_to_serializable( 79 | *args: Any, **kwargs: Any 80 | ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: 81 | """Takes a JSON-RPC request string and dispatches it to method(s), giving responses 82 | as dicts (or None). 83 | """ 84 | return cast( 85 | Union[Dict[str, Any], List[Dict[str, Any]], None], 86 | dispatch_to_response(*args, post_process=to_dict, **kwargs), 87 | ) 88 | 89 | 90 | def dispatch_to_json( 91 | *args: Any, 92 | serializer: Callable[ 93 | [Union[Dict[str, Any], List[Dict[str, Any]], str]], str 94 | ] = json.dumps, 95 | **kwargs: Any, 96 | ) -> str: 97 | """Takes a JSON-RPC request string and dispatches it to method(s), giving a JSON-RPC 98 | response string. 99 | 100 | This is the main public method, it goes through the entire JSON-RPC process - it's a 101 | function from JSON-RPC request string to JSON-RPC response string. 102 | 103 | Args: 104 | serializer: A function to serialize a Python object to json. 105 | The rest: Passed through to dispatch_to_serializable. 106 | """ 107 | response = dispatch_to_serializable(*args, **kwargs) 108 | # Better to respond with the empty string instead of json "null", because "null" is 109 | # an invalid JSON-RPC response. 110 | return "" if response is None else serializer(response) 111 | 112 | 113 | # "dispatch" aliases dispatch_to_json. 114 | dispatch = dispatch_to_json 115 | -------------------------------------------------------------------------------- /jsonrpcserver/methods.py: -------------------------------------------------------------------------------- 1 | """A method is a Python function that can be called by a JSON-RPC request. 2 | 3 | They're held in a dict, a mapping of function names to functions. 4 | 5 | The @method decorator adds a method to jsonrpcserver's internal global_methods dict. 6 | Alternatively pass your own dictionary of methods to `dispatch` with the methods param. 7 | 8 | >>> dispatch(request) # Uses the internal collection of funcs added with @method 9 | >>> dispatch(request, methods={"ping": lambda: "pong"}) # Custom collection 10 | 11 | Methods can take either positional or named arguments, but not both. This is a 12 | limitation of JSON-RPC. 13 | """ 14 | from typing import Any, Callable, Dict, Optional, cast 15 | 16 | from .result import Result 17 | 18 | Method = Callable[..., Result] 19 | Methods = Dict[str, Method] 20 | 21 | global_methods = {} 22 | 23 | 24 | def method( 25 | f: Optional[Method] = None, # pylint: disable=invalid-name 26 | name: Optional[str] = None, 27 | ) -> Callable[..., Any]: 28 | """A decorator to add a function into jsonrpcserver's internal global_methods dict. 29 | The global_methods dict will be used by default unless a methods argument is passed 30 | to `dispatch`. 31 | 32 | Functions can be renamed by passing a name argument: 33 | 34 | @method(name=bar) 35 | def foo(): 36 | ... 37 | """ 38 | 39 | def decorator(func: Method) -> Method: 40 | nonlocal name 41 | global_methods[name or func.__name__] = func 42 | return func 43 | 44 | return decorator(f) if callable(f) else cast(Method, decorator) 45 | -------------------------------------------------------------------------------- /jsonrpcserver/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodinglabs/jsonrpcserver/2d3d07658522f30bae7db3325051f18257fc7791/jsonrpcserver/py.typed -------------------------------------------------------------------------------- /jsonrpcserver/request-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "A JSON RPC 2.0 request", 4 | "oneOf": [ 5 | { 6 | "description": "An individual request", 7 | "$ref": "#/definitions/request" 8 | }, 9 | { 10 | "description": "An array of requests", 11 | "type": "array", 12 | "items": { "$ref": "#/definitions/request" }, 13 | "minItems": 1 14 | } 15 | ], 16 | "definitions": { 17 | "request": { 18 | "type": "object", 19 | "required": [ "jsonrpc", "method" ], 20 | "properties": { 21 | "jsonrpc": { "enum": [ "2.0" ] }, 22 | "method": { 23 | "type": "string" 24 | }, 25 | "id": { 26 | "type": [ "string", "number", "null" ], 27 | "note": [ 28 | "While allowed, null should be avoided: http://www.jsonrpc.org/specification#id1", 29 | "While allowed, a number with a fractional part should be avoided: http://www.jsonrpc.org/specification#id2" 30 | ] 31 | }, 32 | "params": { 33 | "type": [ "array", "object" ] 34 | } 35 | }, 36 | "additionalProperties": false 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /jsonrpcserver/request.py: -------------------------------------------------------------------------------- 1 | """A simple namedtuple to hold a request. 2 | 3 | After parsing a request string, we put the (dict) requests into these Request 4 | namedtuples, simply because they're nicer to work with. 5 | """ 6 | from typing import Any, Dict, List, NamedTuple, Union 7 | 8 | 9 | class Request(NamedTuple): 10 | """JSON-RPC Request""" 11 | 12 | method: str 13 | params: Union[List[Any], Dict[str, Any]] 14 | id: Any # Use NOID for a Notification. 15 | -------------------------------------------------------------------------------- /jsonrpcserver/response.py: -------------------------------------------------------------------------------- 1 | """The response data types. 2 | 3 | https://www.jsonrpc.org/specification#response_object 4 | """ 5 | from typing import Any, Dict, List, NamedTuple, Type, Union 6 | 7 | from oslash.either import Either, Left # type: ignore 8 | 9 | from .codes import ( 10 | ERROR_INVALID_REQUEST, 11 | ERROR_METHOD_NOT_FOUND, 12 | ERROR_PARSE_ERROR, 13 | ERROR_SERVER_ERROR, 14 | ) 15 | from .sentinels import NODATA 16 | 17 | Deserialized = Union[Dict[str, Any], List[Dict[str, Any]]] 18 | 19 | 20 | class SuccessResponse(NamedTuple): 21 | """It would be nice to subclass Success here, adding only id. But it's not possible 22 | to easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) 23 | """ 24 | 25 | result: Any 26 | id: Any 27 | 28 | 29 | class ErrorResponse(NamedTuple): 30 | """It would be nice to subclass Error here, adding only id. But it's not possible to 31 | easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) 32 | """ 33 | 34 | code: int 35 | message: str 36 | data: Any 37 | id: Any 38 | 39 | 40 | Response = Either[ErrorResponse, SuccessResponse] 41 | ResponseType = Type[Either[ErrorResponse, SuccessResponse]] 42 | 43 | 44 | def ParseErrorResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name 45 | """An ErrorResponse with most attributes already populated. 46 | 47 | From the spec: "This (id) member is REQUIRED. It MUST be the same as the value of 48 | the id member in the Request Object. If there was an error in detecting the id in 49 | the Request object (e.g. Parse error/Invalid Request), it MUST be Null." 50 | """ 51 | return ErrorResponse(ERROR_PARSE_ERROR, "Parse error", data, None) 52 | 53 | 54 | def InvalidRequestResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name 55 | """An ErrorResponse with most attributes already populated. 56 | 57 | From the spec: "This (id) member is REQUIRED. It MUST be the same as the value of 58 | the id member in the Request Object. If there was an error in detecting the id in 59 | the Request object (e.g. Parse error/Invalid Request), it MUST be Null." 60 | """ 61 | return ErrorResponse(ERROR_INVALID_REQUEST, "Invalid request", data, None) 62 | 63 | 64 | def MethodNotFoundResponse(data: Any, id: Any) -> ErrorResponse: 65 | """An ErrorResponse with some attributes already populated.""" 66 | # pylint: disable=invalid-name,redefined-builtin 67 | return ErrorResponse(ERROR_METHOD_NOT_FOUND, "Method not found", data, id) 68 | 69 | 70 | def ServerErrorResponse(data: Any, id: Any) -> ErrorResponse: 71 | """An ErrorResponse with some attributes already populated.""" 72 | # pylint: disable=invalid-name,redefined-builtin 73 | return ErrorResponse(ERROR_SERVER_ERROR, "Server error", data, id) 74 | 75 | 76 | def to_error_dict(response: ErrorResponse) -> Dict[str, Any]: 77 | """From ErrorResponse object to dict""" 78 | return { 79 | "jsonrpc": "2.0", 80 | "error": { 81 | "code": response.code, 82 | "message": response.message, 83 | # "data" may be omitted. 84 | **({"data": response.data} if response.data is not NODATA else {}), 85 | }, 86 | "id": response.id, 87 | } 88 | 89 | 90 | def to_success_dict(response: SuccessResponse) -> Dict[str, Any]: 91 | """From SuccessResponse object to dict""" 92 | return {"jsonrpc": "2.0", "result": response.result, "id": response.id} 93 | 94 | 95 | def to_dict(response: ResponseType) -> Dict[str, Any]: 96 | """Serialize either an error or success response object to dict""" 97 | # pylint: disable=protected-access 98 | return ( 99 | to_error_dict(response._error) 100 | if isinstance(response, Left) 101 | else to_success_dict(response._value) 102 | ) 103 | 104 | 105 | def to_serializable( 106 | response: Union[ResponseType, List[ResponseType], None] 107 | ) -> Union[Deserialized, None]: 108 | """Serialize a response object (or list of them), to a dict, or list of them.""" 109 | if response is None: 110 | return None 111 | if isinstance(response, List): 112 | return [to_dict(r) for r in response] 113 | return to_dict(response) 114 | -------------------------------------------------------------------------------- /jsonrpcserver/result.py: -------------------------------------------------------------------------------- 1 | """Result data types - the results of calling a method. 2 | 3 | Results are the JSON-RPC response objects 4 | (https://www.jsonrpc.org/specification#response_object), minus the "jsonrpc" and "id" 5 | parts - the library takes care of these parts for you. 6 | 7 | The public functions are Success, Error and InvalidParams. 8 | """ 9 | from typing import Any, NamedTuple 10 | 11 | from oslash.either import Either, Left, Right # type: ignore 12 | 13 | from .codes import ERROR_INTERNAL_ERROR, ERROR_INVALID_PARAMS, ERROR_METHOD_NOT_FOUND 14 | from .sentinels import NODATA 15 | 16 | # pylint: disable=missing-class-docstring,missing-function-docstring,invalid-name 17 | 18 | 19 | class SuccessResult(NamedTuple): 20 | result: Any = None 21 | 22 | def __repr__(self) -> str: 23 | return f"SuccessResult({self.result!r})" 24 | 25 | 26 | class ErrorResult(NamedTuple): 27 | code: int 28 | message: str 29 | data: Any = NODATA # The spec says this value may be omitted 30 | 31 | def __repr__(self) -> str: 32 | return f"ErrorResult(code={self.code!r}, message={self.message!r}, data={self.data!r})" 33 | 34 | 35 | # Union of the two valid result types 36 | Result = Either[ErrorResult, SuccessResult] 37 | 38 | 39 | # Helpers 40 | 41 | 42 | def MethodNotFoundResult(data: Any) -> ErrorResult: 43 | return ErrorResult(ERROR_METHOD_NOT_FOUND, "Method not found", data) 44 | 45 | 46 | def InternalErrorResult(data: Any) -> ErrorResult: 47 | return ErrorResult(ERROR_INTERNAL_ERROR, "Internal error", data) 48 | 49 | 50 | def InvalidParamsResult(data: Any = NODATA) -> ErrorResult: 51 | return ErrorResult(ERROR_INVALID_PARAMS, "Invalid params", data) 52 | 53 | 54 | # Helpers (the public functions) 55 | 56 | 57 | def Success(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: 58 | return Right(SuccessResult(*args, **kwargs)) 59 | 60 | 61 | def Error(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: 62 | return Left(ErrorResult(*args, **kwargs)) 63 | 64 | 65 | def InvalidParams(*args: Any, **kwargs: Any) -> Either[ErrorResult, SuccessResult]: 66 | """InvalidParams is a shortcut to save you from having to pass the Invalid Params 67 | JSON-RPC code to Error. 68 | """ 69 | return Left(InvalidParamsResult(*args, **kwargs)) 70 | -------------------------------------------------------------------------------- /jsonrpcserver/sentinels.py: -------------------------------------------------------------------------------- 1 | """Sentinels - these are used to indicate no data is present. 2 | 3 | We can't use None, because None may be a valid piece of data. 4 | """ 5 | 6 | import sys 7 | 8 | 9 | class Sentinel: 10 | """Use this class to create a unique object. 11 | 12 | Has a nicer repr than `object()`. 13 | """ 14 | 15 | # pylint: disable=too-few-public-methods 16 | def __init__(self, name: str): 17 | self.name = name 18 | 19 | def __repr__(self) -> str: 20 | return f"<{sys.intern(str(self.name)).rsplit('.', 1)[-1]}>" 21 | 22 | 23 | NOCONTEXT = Sentinel("NoContext") 24 | NODATA = Sentinel("NoData") 25 | NOID = Sentinel("NoId") 26 | -------------------------------------------------------------------------------- /jsonrpcserver/server.py: -------------------------------------------------------------------------------- 1 | """A simple development server for serving JSON-RPC requests using Python's builtin 2 | http.server module. 3 | """ 4 | import logging 5 | from http.server import BaseHTTPRequestHandler, HTTPServer 6 | 7 | from .main import dispatch 8 | 9 | 10 | class RequestHandler(BaseHTTPRequestHandler): 11 | """Handle HTTP requests""" 12 | 13 | def do_POST(self) -> None: # pylint: disable=invalid-name 14 | """Handle POST request""" 15 | response = dispatch( 16 | self.rfile.read(int(str(self.headers["Content-Length"]))).decode() 17 | ) 18 | if response is not None: 19 | self.send_response(200) 20 | self.send_header("Content-type", "application/json") 21 | self.end_headers() 22 | self.wfile.write(str(response).encode()) 23 | 24 | 25 | def serve(name: str = "", port: int = 5000) -> None: 26 | """A simple function to serve HTTP requests""" 27 | logging.info(" * Listening on port %s", port) 28 | HTTPServer((name, port), RequestHandler).serve_forever() 29 | -------------------------------------------------------------------------------- /jsonrpcserver/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions""" 2 | from functools import reduce 3 | from typing import Any, Callable, List 4 | 5 | # pylint: disable=invalid-name 6 | 7 | 8 | def identity(x: Any) -> Any: 9 | """Returns the argument.""" 10 | return x 11 | 12 | 13 | def compose(*funcs: Callable[..., Any]) -> Callable[..., Any]: 14 | """Compose two or more functions producing a single composite function.""" 15 | return reduce(lambda f, g: lambda *a, **kw: f(g(*a, **kw)), funcs) 16 | 17 | 18 | def make_list(x: Any) -> List[Any]: 19 | """Puts a value into a list if it's not already.""" 20 | return x if isinstance(x, list) else [x] 21 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodinglabs/jsonrpcserver/2d3d07658522f30bae7db3325051f18257fc7791/logo.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: jsonrpcserver 2 | site_url: https://explodinglabs.com/jsonrpcserver/ 3 | repo_url: https://github.com/explodinglabs/jsonrpcserver 4 | theme: 5 | name: material 6 | features: 7 | - navigation.footer 8 | - palette.toggle 9 | - content.code.copy 10 | palette: 11 | # Palette toggle for automatic mode 12 | - media: "(prefers-color-scheme)" 13 | primary: pink 14 | toggle: 15 | icon: material/brightness-auto 16 | name: Switch to light mode 17 | 18 | # Palette toggle for light mode 19 | - media: "(prefers-color-scheme: light)" 20 | scheme: default 21 | primary: pink 22 | toggle: 23 | icon: material/brightness-7 24 | name: Switch to dark mode 25 | 26 | # Palette toggle for dark mode 27 | - media: "(prefers-color-scheme: dark)" 28 | scheme: slate 29 | primary: pink 30 | toggle: 31 | icon: material/brightness-4 32 | name: Switch to system preference 33 | markdown_extensions: 34 | - pymdownx.highlight 35 | - pymdownx.superfences 36 | nav: 37 | - Home: index.md 38 | - Installation: installation.md 39 | - Methods: methods.md 40 | - Dispatch: dispatch.md 41 | - Async: async.md 42 | - FAQ: faq.md 43 | - Examples: examples.md 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """setup.py""" 2 | from setuptools import setup 3 | 4 | with open("README.md", encoding="utf-8") as f: 5 | README = f.read() 6 | 7 | setup( 8 | author="Beau Barker", 9 | author_email="beau@explodinglabs.com", 10 | classifiers=[ 11 | "Programming Language :: Python :: 3.8", 12 | "Programming Language :: Python :: 3.9", 13 | "Programming Language :: Python :: 3.10", 14 | ], 15 | description="Process JSON-RPC requests", 16 | extras_require={ 17 | "examples": [ 18 | "aiohttp", 19 | "aiozmq", 20 | "flask", 21 | "flask-socketio", 22 | "gmqtt", 23 | "pyzmq", 24 | "tornado", 25 | "websockets", 26 | "werkzeug", 27 | ], 28 | "test": [ 29 | "pytest", 30 | "pytest-cov", 31 | "tox", 32 | ], 33 | }, 34 | include_package_data=True, 35 | install_requires=["jsonschema<5", "oslash<1"], 36 | license="MIT", 37 | long_description=README, 38 | long_description_content_type="text/markdown", 39 | name="jsonrpcserver", 40 | packages=["jsonrpcserver"], 41 | url="https://github.com/explodinglabs/jsonrpcserver", 42 | version="5.0.9", 43 | # Be PEP 561 compliant 44 | # https://mypy.readthedocs.io/en/stable/installed_packages.html#making-pep-561-compatible-packages 45 | zip_safe=False, 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/explodinglabs/jsonrpcserver/2d3d07658522f30bae7db3325051f18257fc7791/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_async_dispatcher.py: -------------------------------------------------------------------------------- 1 | """Test async_dispatcher.py""" 2 | from unittest.mock import Mock, patch 3 | 4 | import pytest 5 | from oslash.either import Left, Right # type: ignore 6 | 7 | from jsonrpcserver.async_dispatcher import ( 8 | call, 9 | dispatch_deserialized, 10 | dispatch_request, 11 | dispatch_to_response_pure, 12 | ) 13 | from jsonrpcserver.codes import ERROR_INTERNAL_ERROR, ERROR_SERVER_ERROR 14 | from jsonrpcserver.exceptions import JsonRpcError 15 | from jsonrpcserver.main import default_deserializer, default_validator 16 | from jsonrpcserver.request import Request 17 | from jsonrpcserver.response import ErrorResponse, SuccessResponse 18 | from jsonrpcserver.result import ErrorResult, Result, Success, SuccessResult 19 | from jsonrpcserver.sentinels import NOCONTEXT, NODATA 20 | from jsonrpcserver.utils import identity 21 | 22 | # pylint: disable=missing-function-docstring,duplicate-code 23 | 24 | 25 | async def ping() -> Result: 26 | return Success("pong") 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_call() -> None: 31 | assert await call(Request("ping", [], 1), NOCONTEXT, ping) == Right( 32 | SuccessResult("pong") 33 | ) 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_call_raising_jsonrpcerror() -> None: 38 | def method() -> None: 39 | raise JsonRpcError(code=1, message="foo", data=NODATA) 40 | 41 | assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( 42 | ErrorResult(1, "foo") 43 | ) 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_call_raising_exception() -> None: 48 | def method() -> None: 49 | raise ValueError("foo") 50 | 51 | assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( 52 | ErrorResult(ERROR_INTERNAL_ERROR, "Internal error", "foo") 53 | ) 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_dispatch_request() -> None: 58 | request = Request("ping", [], 1) 59 | assert await dispatch_request({"ping": ping}, NOCONTEXT, request) == ( 60 | request, 61 | Right(SuccessResult("pong")), 62 | ) 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_dispatch_deserialized() -> None: 67 | assert await dispatch_deserialized( 68 | {"ping": ping}, 69 | NOCONTEXT, 70 | identity, 71 | {"jsonrpc": "2.0", "method": "ping", "id": 1}, 72 | ) == Right(SuccessResponse("pong", 1)) 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_dispatch_to_response_pure_success() -> None: 77 | assert await dispatch_to_response_pure( 78 | deserializer=default_deserializer, 79 | validator=default_validator, 80 | post_process=identity, 81 | context=NOCONTEXT, 82 | methods={"ping": ping}, 83 | request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', 84 | ) == Right(SuccessResponse("pong", 1)) 85 | 86 | 87 | @patch("jsonrpcserver.async_dispatcher.dispatch_request", side_effect=ValueError("foo")) 88 | @pytest.mark.asyncio 89 | async def test_dispatch_to_response_pure_server_error(*_: Mock) -> None: 90 | async def hello() -> Result: 91 | return Success() 92 | 93 | assert await dispatch_to_response_pure( 94 | deserializer=default_deserializer, 95 | validator=default_validator, 96 | post_process=identity, 97 | context=NOCONTEXT, 98 | methods={"hello": hello}, 99 | request='{"jsonrpc": "2.0", "method": "hello", "id": 1}', 100 | ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "hello", None)) 101 | -------------------------------------------------------------------------------- /tests/test_async_main.py: -------------------------------------------------------------------------------- 1 | """Test async_main.py""" 2 | import pytest 3 | from oslash.either import Right # type: ignore 4 | 5 | from jsonrpcserver.async_main import ( 6 | dispatch_to_json, 7 | dispatch_to_response, 8 | dispatch_to_serializable, 9 | ) 10 | from jsonrpcserver.response import SuccessResponse 11 | from jsonrpcserver.result import Result, Success 12 | 13 | # pylint: disable=missing-function-docstring 14 | 15 | 16 | async def ping() -> Result: 17 | return Success("pong") 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_dispatch_to_response() -> None: 22 | assert await dispatch_to_response( 23 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 24 | ) == Right(SuccessResponse("pong", 1)) 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_dispatch_to_serializable() -> None: 29 | assert await dispatch_to_serializable( 30 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 31 | ) == {"jsonrpc": "2.0", "result": "pong", "id": 1} 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_dispatch_to_json() -> None: 36 | assert ( 37 | await dispatch_to_json( 38 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 39 | ) 40 | == '{"jsonrpc": "2.0", "result": "pong", "id": 1}' 41 | ) 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_dispatch_to_json_notification() -> None: 46 | assert ( 47 | await dispatch_to_json('{"jsonrpc": "2.0", "method": "ping"}', {"ping": ping}) 48 | == "" 49 | ) 50 | -------------------------------------------------------------------------------- /tests/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | """Test dispatcher.py 2 | 3 | TODO: Add tests for dispatch_requests (non-pure version) 4 | """ 5 | import json 6 | from typing import Any, Callable, Dict 7 | from unittest.mock import Mock, patch, sentinel 8 | 9 | import pytest 10 | from oslash.either import Left, Right # type: ignore 11 | 12 | from jsonrpcserver.codes import ( 13 | ERROR_INTERNAL_ERROR, 14 | ERROR_INVALID_PARAMS, 15 | ERROR_INVALID_REQUEST, 16 | ERROR_METHOD_NOT_FOUND, 17 | ERROR_PARSE_ERROR, 18 | ERROR_SERVER_ERROR, 19 | ) 20 | from jsonrpcserver.dispatcher import ( 21 | call, 22 | create_request, 23 | dispatch_deserialized, 24 | dispatch_request, 25 | dispatch_to_response_pure, 26 | extract_args, 27 | extract_kwargs, 28 | extract_list, 29 | get_method, 30 | not_notification, 31 | to_response, 32 | validate_args, 33 | validate_request, 34 | ) 35 | from jsonrpcserver.exceptions import JsonRpcError 36 | from jsonrpcserver.main import ( 37 | default_deserializer, 38 | default_validator, 39 | dispatch, 40 | dispatch_to_response, 41 | ) 42 | from jsonrpcserver.methods import method 43 | from jsonrpcserver.request import Request 44 | from jsonrpcserver.response import ErrorResponse, SuccessResponse 45 | from jsonrpcserver.result import ( 46 | ErrorResult, 47 | InvalidParams, 48 | Result, 49 | Success, 50 | SuccessResult, 51 | ) 52 | from jsonrpcserver.sentinels import NOCONTEXT, NODATA, NOID 53 | from jsonrpcserver.utils import identity 54 | 55 | # pylint: disable=missing-function-docstring,missing-class-docstring,too-few-public-methods,unnecessary-lambda-assignment,invalid-name,disallowed-name 56 | 57 | 58 | def ping() -> Result: 59 | return Success("pong") 60 | 61 | 62 | # extract_list 63 | 64 | 65 | def test_extract_list() -> None: 66 | assert extract_list(False, [SuccessResponse("foo", 1)]) == SuccessResponse("foo", 1) 67 | 68 | 69 | def test_extract_list_notification() -> None: 70 | assert extract_list(False, [None]) is None 71 | 72 | 73 | def test_extract_list_batch() -> None: 74 | assert extract_list(True, [SuccessResponse("foo", 1)]) == [ 75 | SuccessResponse("foo", 1) 76 | ] 77 | 78 | 79 | def test_extract_list_batch_all_notifications() -> None: 80 | assert extract_list(True, []) is None 81 | 82 | 83 | # to_response 84 | 85 | 86 | def test_to_response_SuccessResult() -> None: 87 | assert to_response( 88 | Request("ping", [], sentinel.id), Right(SuccessResult(sentinel.result)) 89 | ) == Right(SuccessResponse(sentinel.result, sentinel.id)) 90 | 91 | 92 | def test_to_response_ErrorResult() -> None: 93 | assert ( 94 | to_response( 95 | Request("ping", [], sentinel.id), 96 | Left( 97 | ErrorResult( 98 | code=sentinel.code, message=sentinel.message, data=sentinel.data 99 | ) 100 | ), 101 | ) 102 | ) == Left( 103 | ErrorResponse(sentinel.code, sentinel.message, sentinel.data, sentinel.id) 104 | ) 105 | 106 | 107 | def test_to_response_InvalidParams() -> None: 108 | assert to_response( 109 | Request("ping", [], sentinel.id), InvalidParams(sentinel.data) 110 | ) == Left(ErrorResponse(-32602, "Invalid params", sentinel.data, sentinel.id)) 111 | 112 | 113 | def test_to_response_InvalidParams_no_data() -> None: 114 | assert to_response(Request("ping", [], sentinel.id), InvalidParams()) == Left( 115 | ErrorResponse(-32602, "Invalid params", NODATA, sentinel.id) 116 | ) 117 | 118 | 119 | def test_to_response_notification() -> None: 120 | with pytest.raises(AssertionError): 121 | to_response(Request("ping", [], NOID), SuccessResult(result=sentinel.result)) 122 | 123 | 124 | # extract_args 125 | 126 | 127 | def test_extract_args() -> None: 128 | assert extract_args(Request("ping", [], NOID), NOCONTEXT) == [] 129 | 130 | 131 | def test_extract_args_with_context() -> None: 132 | assert extract_args(Request("ping", ["bar"], NOID), "foo") == ["foo", "bar"] 133 | 134 | 135 | # extract_kwargs 136 | 137 | 138 | def test_extract_kwargs() -> None: 139 | assert extract_kwargs(Request("ping", {"foo": "bar"}, NOID)) == {"foo": "bar"} 140 | 141 | 142 | # validate_result 143 | 144 | 145 | def test_validate_result_no_arguments() -> None: 146 | f = lambda: None 147 | assert validate_args(Request("f", [], NOID), NOCONTEXT, f) == Right(f) 148 | 149 | 150 | def test_validate_result_no_arguments_too_many_positionals() -> None: 151 | assert validate_args(Request("f", ["foo"], NOID), NOCONTEXT, lambda: None) == Left( 152 | ErrorResult( 153 | code=ERROR_INVALID_PARAMS, 154 | message="Invalid params", 155 | data="too many positional arguments", 156 | ) 157 | ) 158 | 159 | 160 | def test_validate_result_positionals() -> None: 161 | f = lambda x: None 162 | assert validate_args(Request("f", [1], NOID), NOCONTEXT, f) == Right(f) 163 | 164 | 165 | def test_validate_result_positionals_not_passed() -> None: 166 | assert validate_args( 167 | Request("f", {"foo": "bar"}, NOID), NOCONTEXT, lambda x: None 168 | ) == Left( 169 | ErrorResult( 170 | ERROR_INVALID_PARAMS, "Invalid params", "missing a required argument: 'x'" 171 | ) 172 | ) 173 | 174 | 175 | def test_validate_result_keywords() -> None: 176 | f = lambda **kwargs: None 177 | assert validate_args(Request("f", {"foo": "bar"}, NOID), NOCONTEXT, f) == Right(f) 178 | 179 | 180 | def test_validate_result_object_method() -> None: 181 | class FooClass: 182 | def f(self, *_: str) -> str: 183 | return "" 184 | 185 | g = FooClass().f 186 | assert validate_args(Request("g", ["one", "two"], NOID), NOCONTEXT, g) == Right(g) 187 | 188 | 189 | # call 190 | 191 | 192 | def test_call() -> None: 193 | assert call(Request("ping", [], 1), NOCONTEXT, ping) == Right(SuccessResult("pong")) 194 | 195 | 196 | def test_call_raising_jsonrpcerror() -> None: 197 | def method_() -> None: 198 | raise JsonRpcError(code=1, message="foo", data=NODATA) 199 | 200 | assert call(Request("ping", [], 1), NOCONTEXT, method_) == Left( 201 | ErrorResult(1, "foo") 202 | ) 203 | 204 | 205 | def test_call_raising_exception() -> None: 206 | def method_() -> None: 207 | raise ValueError("foo") 208 | 209 | assert call(Request("ping", [], 1), NOCONTEXT, method_) == Left( 210 | ErrorResult(ERROR_INTERNAL_ERROR, "Internal error", "foo") 211 | ) 212 | 213 | 214 | # validate_args 215 | 216 | 217 | @pytest.mark.parametrize( 218 | "argument,value", 219 | [ 220 | ( 221 | validate_args(Request("ping", [], 1), NOCONTEXT, ping), 222 | Right(ping), 223 | ), 224 | ( 225 | validate_args(Request("ping", ["foo"], 1), NOCONTEXT, ping), 226 | Left( 227 | ErrorResult( 228 | ERROR_INVALID_PARAMS, 229 | "Invalid params", 230 | "too many positional arguments", 231 | ) 232 | ), 233 | ), 234 | ], 235 | ) 236 | def test_validate_args(argument: Result, value: Result) -> None: 237 | assert argument == value 238 | 239 | 240 | # get_method 241 | 242 | 243 | @pytest.mark.parametrize( 244 | "argument,value", 245 | [ 246 | ( 247 | get_method({"ping": ping}, "ping"), 248 | Right(ping), 249 | ), 250 | ( 251 | get_method({"ping": ping}, "non-existant"), 252 | Left( 253 | ErrorResult(ERROR_METHOD_NOT_FOUND, "Method not found", "non-existant") 254 | ), 255 | ), 256 | ], 257 | ) 258 | def test_get_method(argument: Result, value: Result) -> None: 259 | assert argument == value 260 | 261 | 262 | # dispatch_request 263 | 264 | 265 | def test_dispatch_request() -> None: 266 | request = Request("ping", [], 1) 267 | assert dispatch_request({"ping": ping}, NOCONTEXT, request) == ( 268 | request, 269 | Right(SuccessResult("pong")), 270 | ) 271 | 272 | 273 | def test_dispatch_request_with_context() -> None: 274 | def ping_with_context(context: Any) -> Result: 275 | assert context is sentinel.context 276 | return Success() 277 | 278 | dispatch_request( 279 | {"ping_with_context": ping_with_context}, 280 | sentinel.context, 281 | Request("ping_with_context", [], 1), 282 | ) 283 | # Assert is in the method 284 | 285 | 286 | # create_request 287 | 288 | 289 | def test_create_request() -> None: 290 | request = create_request({"jsonrpc": "2.0", "method": "ping"}) 291 | assert isinstance(request, Request) 292 | 293 | 294 | # not_notification 295 | 296 | 297 | def test_not_notification() -> None: 298 | assert not_notification((Request("ping", [], 1), SuccessResult("pong"))) is True 299 | 300 | 301 | def test_not_notification_false() -> None: 302 | assert not_notification((Request("ping", [], NOID), SuccessResult("pong"))) is False 303 | 304 | 305 | # dispatch_deserialized 306 | 307 | 308 | def test_dispatch_deserialized() -> None: 309 | assert dispatch_deserialized( 310 | methods={"ping": ping}, 311 | context=NOCONTEXT, 312 | post_process=identity, 313 | deserialized={"jsonrpc": "2.0", "method": "ping", "id": 1}, 314 | ) == Right(SuccessResponse("pong", 1)) 315 | 316 | 317 | # validate_request 318 | 319 | 320 | def test_validate_request() -> None: 321 | request = {"jsonrpc": "2.0", "method": "ping"} 322 | assert validate_request(default_validator, request) == Right(request) 323 | 324 | 325 | def test_validate_request_invalid() -> None: 326 | assert validate_request(default_validator, {"jsonrpc": "2.0"}) == Left( 327 | ErrorResponse( 328 | ERROR_INVALID_REQUEST, 329 | "Invalid request", 330 | "The request failed schema validation", 331 | None, 332 | ) 333 | ) 334 | 335 | 336 | # dispatch_to_response_pure 337 | 338 | 339 | def test_dispatch_to_response_pure() -> None: 340 | assert dispatch_to_response_pure( 341 | deserializer=default_deserializer, 342 | validator=default_validator, 343 | post_process=identity, 344 | context=NOCONTEXT, 345 | methods={"ping": ping}, 346 | request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', 347 | ) == Right(SuccessResponse("pong", 1)) 348 | 349 | 350 | def test_dispatch_to_response_pure_parse_error() -> None: 351 | """Unable to parse, must return an error""" 352 | assert dispatch_to_response_pure( 353 | deserializer=default_deserializer, 354 | validator=default_validator, 355 | post_process=identity, 356 | context=NOCONTEXT, 357 | methods={"ping": ping}, 358 | request="{", 359 | ) == Left( 360 | ErrorResponse( 361 | ERROR_PARSE_ERROR, 362 | "Parse error", 363 | "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", 364 | None, 365 | ) 366 | ) 367 | 368 | 369 | def test_dispatch_to_response_pure_invalid_request() -> None: 370 | """Invalid JSON-RPC, must return an error. (impossible to determine if 371 | notification). 372 | """ 373 | assert dispatch_to_response_pure( 374 | deserializer=default_deserializer, 375 | validator=default_validator, 376 | post_process=identity, 377 | context=NOCONTEXT, 378 | methods={"ping": ping}, 379 | request="{}", 380 | ) == Left( 381 | ErrorResponse( 382 | ERROR_INVALID_REQUEST, 383 | "Invalid request", 384 | "The request failed schema validation", 385 | None, 386 | ) 387 | ) 388 | 389 | 390 | def test_dispatch_to_response_pure_method_not_found() -> None: 391 | assert dispatch_to_response_pure( 392 | deserializer=default_deserializer, 393 | validator=default_validator, 394 | post_process=identity, 395 | context=NOCONTEXT, 396 | methods={}, 397 | request='{"jsonrpc": "2.0", "method": "non_existant", "id": 1}', 398 | ) == Left( 399 | ErrorResponse(ERROR_METHOD_NOT_FOUND, "Method not found", "non_existant", 1) 400 | ) 401 | 402 | 403 | def test_dispatch_to_response_pure_invalid_params_auto() -> None: 404 | def f(colour: str, size: str) -> Result: # pylint: disable=unused-argument 405 | return Success() 406 | 407 | assert dispatch_to_response_pure( 408 | deserializer=default_deserializer, 409 | validator=default_validator, 410 | post_process=identity, 411 | context=NOCONTEXT, 412 | methods={"f": f}, 413 | request='{"jsonrpc": "2.0", "method": "f", "params": {"colour":"blue"}, "id": 1}', 414 | ) == Left( 415 | ErrorResponse( 416 | ERROR_INVALID_PARAMS, 417 | "Invalid params", 418 | "missing a required argument: 'size'", 419 | 1, 420 | ) 421 | ) 422 | 423 | 424 | def test_dispatch_to_response_pure_invalid_params_explicitly_returned() -> None: 425 | def foo(colour: str) -> Result: 426 | if colour not in ("orange", "red", "yellow"): 427 | return InvalidParams() 428 | return Success() 429 | 430 | assert dispatch_to_response_pure( 431 | deserializer=default_deserializer, 432 | validator=default_validator, 433 | post_process=identity, 434 | context=NOCONTEXT, 435 | methods={"foo": foo}, 436 | request='{"jsonrpc": "2.0", "method": "foo", "params": ["blue"], "id": 1}', 437 | ) == Left(ErrorResponse(ERROR_INVALID_PARAMS, "Invalid params", NODATA, 1)) 438 | 439 | 440 | def test_dispatch_to_response_pure_internal_error() -> None: 441 | def foo() -> Result: 442 | raise ValueError("foo") 443 | 444 | assert dispatch_to_response_pure( 445 | deserializer=default_deserializer, 446 | validator=default_validator, 447 | post_process=identity, 448 | context=NOCONTEXT, 449 | methods={"foo": foo}, 450 | request='{"jsonrpc": "2.0", "method": "foo", "id": 1}', 451 | ) == Left(ErrorResponse(ERROR_INTERNAL_ERROR, "Internal error", "foo", 1)) 452 | 453 | 454 | @patch("jsonrpcserver.dispatcher.dispatch_request", side_effect=ValueError("foo")) 455 | def test_dispatch_to_response_pure_server_error(*_: Mock) -> None: 456 | def foo() -> Result: 457 | return Success() 458 | 459 | assert dispatch_to_response_pure( 460 | deserializer=default_deserializer, 461 | validator=default_validator, 462 | post_process=identity, 463 | context=NOCONTEXT, 464 | methods={"foo": foo}, 465 | request='{"jsonrpc": "2.0", "method": "foo", "id": 1}', 466 | ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) 467 | 468 | 469 | def test_dispatch_to_response_pure_invalid_result() -> None: 470 | """Methods should return a Result, otherwise we get an Internal Error response.""" 471 | 472 | def not_a_result() -> None: 473 | return None 474 | 475 | assert dispatch_to_response_pure( 476 | deserializer=default_deserializer, 477 | validator=default_validator, 478 | post_process=identity, 479 | context=NOCONTEXT, 480 | methods={"not_a_result": not_a_result}, 481 | request='{"jsonrpc": "2.0", "method": "not_a_result", "id": 1}', 482 | ) == Left( 483 | ErrorResponse( 484 | ERROR_INTERNAL_ERROR, 485 | "Internal error", 486 | "The method did not return a valid Result (returned None)", 487 | 1, 488 | ) 489 | ) 490 | 491 | 492 | def test_dispatch_to_response_pure_raising_exception() -> None: 493 | """Allow raising an exception to return an error.""" 494 | 495 | def raise_exception() -> None: 496 | raise JsonRpcError(code=0, message="foo", data="bar") 497 | 498 | assert dispatch_to_response_pure( 499 | deserializer=default_deserializer, 500 | validator=default_validator, 501 | post_process=identity, 502 | context=NOCONTEXT, 503 | methods={"raise_exception": raise_exception}, 504 | request='{"jsonrpc": "2.0", "method": "raise_exception", "id": 1}', 505 | ) == Left(ErrorResponse(0, "foo", "bar", 1)) 506 | 507 | 508 | # dispatch_to_response_pure -- Notifications 509 | 510 | 511 | def test_dispatch_to_response_pure_notification() -> None: 512 | assert ( 513 | dispatch_to_response_pure( 514 | deserializer=default_deserializer, 515 | validator=default_validator, 516 | post_process=identity, 517 | context=NOCONTEXT, 518 | methods={"ping": ping}, 519 | request='{"jsonrpc": "2.0", "method": "ping"}', 520 | ) 521 | is None 522 | ) 523 | 524 | 525 | def test_dispatch_to_response_pure_notification_parse_error() -> None: 526 | """Unable to parse, must return an error""" 527 | assert dispatch_to_response_pure( 528 | deserializer=default_deserializer, 529 | validator=default_validator, 530 | post_process=identity, 531 | context=NOCONTEXT, 532 | methods={"ping": ping}, 533 | request="{", 534 | ) == Left( 535 | ErrorResponse( 536 | ERROR_PARSE_ERROR, 537 | "Parse error", 538 | "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", 539 | None, 540 | ) 541 | ) 542 | 543 | 544 | def test_dispatch_to_response_pure_notification_invalid_request() -> None: 545 | """Invalid JSON-RPC, must return an error. (impossible to determine if notification)""" 546 | assert dispatch_to_response_pure( 547 | deserializer=default_deserializer, 548 | validator=default_validator, 549 | post_process=identity, 550 | context=NOCONTEXT, 551 | methods={"ping": ping}, 552 | request="{}", 553 | ) == Left( 554 | ErrorResponse( 555 | ERROR_INVALID_REQUEST, 556 | "Invalid request", 557 | "The request failed schema validation", 558 | None, 559 | ) 560 | ) 561 | 562 | 563 | def test_dispatch_to_response_pure_notification_method_not_found() -> None: 564 | assert ( 565 | dispatch_to_response_pure( 566 | deserializer=default_deserializer, 567 | validator=default_validator, 568 | post_process=identity, 569 | context=NOCONTEXT, 570 | methods={}, 571 | request='{"jsonrpc": "2.0", "method": "non_existant"}', 572 | ) 573 | is None 574 | ) 575 | 576 | 577 | def test_dispatch_to_response_pure_notification_invalid_params_auto() -> None: 578 | def foo(colour: str, size: str) -> Result: # pylint: disable=unused-argument 579 | return Success() 580 | 581 | assert ( 582 | dispatch_to_response_pure( 583 | deserializer=default_deserializer, 584 | validator=default_validator, 585 | post_process=identity, 586 | context=NOCONTEXT, 587 | methods={"foo": foo}, 588 | request='{"jsonrpc": "2.0", "method": "foo", "params": {"colour":"blue"}}', 589 | ) 590 | is None 591 | ) 592 | 593 | 594 | def test_dispatch_to_response_pure_invalid_params_notification_explicitly_returned() -> None: 595 | def foo(colour: str) -> Result: 596 | if colour not in ("orange", "red", "yellow"): 597 | return InvalidParams() 598 | return Success() 599 | 600 | assert ( 601 | dispatch_to_response_pure( 602 | deserializer=default_deserializer, 603 | validator=default_validator, 604 | post_process=identity, 605 | context=NOCONTEXT, 606 | methods={"foo": foo}, 607 | request='{"jsonrpc": "2.0", "method": "foo", "params": ["blue"]}', 608 | ) 609 | is None 610 | ) 611 | 612 | 613 | def test_dispatch_to_response_pure_notification_internal_error() -> None: 614 | def foo(bar: str) -> Result: 615 | raise ValueError 616 | 617 | assert ( 618 | dispatch_to_response_pure( 619 | deserializer=default_deserializer, 620 | validator=default_validator, 621 | post_process=identity, 622 | context=NOCONTEXT, 623 | methods={"foo": foo}, 624 | request='{"jsonrpc": "2.0", "method": "foo"}', 625 | ) 626 | is None 627 | ) 628 | 629 | 630 | @patch("jsonrpcserver.dispatcher.dispatch_request", side_effect=ValueError("foo")) 631 | def test_dispatch_to_response_pure_notification_server_error(*_: Mock) -> None: 632 | def foo() -> Result: 633 | return Success() 634 | 635 | assert dispatch_to_response_pure( 636 | deserializer=default_deserializer, 637 | validator=default_validator, 638 | post_process=identity, 639 | context=NOCONTEXT, 640 | methods={"foo": foo}, 641 | request='{"jsonrpc": "2.0", "method": "foo"}', 642 | ) == Left(ErrorResponse(ERROR_SERVER_ERROR, "Server error", "foo", None)) 643 | 644 | 645 | def test_dispatch_to_response_pure_notification_invalid_result() -> None: 646 | """Methods should return a Result, otherwise we get an Internal Error response.""" 647 | 648 | def not_a_result() -> None: 649 | return None 650 | 651 | assert ( 652 | dispatch_to_response_pure( 653 | deserializer=default_deserializer, 654 | validator=default_validator, 655 | post_process=identity, 656 | context=NOCONTEXT, 657 | methods={"not_a_result": not_a_result}, 658 | request='{"jsonrpc": "2.0", "method": "not_a_result"}', 659 | ) 660 | is None 661 | ) 662 | 663 | 664 | def test_dispatch_to_response_pure_notification_raising_exception() -> None: 665 | """Allow raising an exception to return an error.""" 666 | 667 | def raise_exception() -> None: 668 | raise JsonRpcError(code=0, message="foo", data="bar") 669 | 670 | assert ( 671 | dispatch_to_response_pure( 672 | deserializer=default_deserializer, 673 | validator=default_validator, 674 | post_process=identity, 675 | context=NOCONTEXT, 676 | methods={"raise_exception": raise_exception}, 677 | request='{"jsonrpc": "2.0", "method": "raise_exception"}', 678 | ) 679 | is None 680 | ) 681 | 682 | 683 | # dispatch_to_response 684 | 685 | 686 | def test_dispatch_to_response() -> None: 687 | response = dispatch_to_response( 688 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 689 | ) 690 | assert response == Right(SuccessResponse("pong", 1)) 691 | 692 | 693 | def test_dispatch_to_response_with_global_methods() -> None: 694 | @method 695 | def ping() -> Result: # pylint: disable=redefined-outer-name 696 | return Success("ping") 697 | 698 | response = dispatch_to_response('{"jsonrpc": "2.0", "method": "ping", "id": 1}') 699 | assert response == Right(SuccessResponse("pong", 1)) 700 | 701 | 702 | # The remaining tests are direct from the examples in the specification 703 | 704 | 705 | def test_examples_positionals() -> None: 706 | def subtract(minuend: int, subtrahend: int) -> Result: 707 | return Success(minuend - subtrahend) 708 | 709 | response = dispatch_to_response_pure( 710 | methods={"subtract": subtract}, 711 | context=NOCONTEXT, 712 | validator=default_validator, 713 | post_process=identity, 714 | deserializer=default_deserializer, 715 | request='{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', 716 | ) 717 | assert response == Right(SuccessResponse(19, 1)) 718 | 719 | # Second example 720 | response = dispatch_to_response_pure( 721 | methods={"subtract": subtract}, 722 | context=NOCONTEXT, 723 | validator=default_validator, 724 | post_process=identity, 725 | deserializer=default_deserializer, 726 | request='{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', 727 | ) 728 | assert response == Right(SuccessResponse(-19, 2)) 729 | 730 | 731 | def test_examples_nameds() -> None: 732 | def subtract(**kwargs: int) -> Result: 733 | return Success(kwargs["minuend"] - kwargs["subtrahend"]) 734 | 735 | response = dispatch_to_response_pure( 736 | methods={"subtract": subtract}, 737 | context=NOCONTEXT, 738 | validator=default_validator, 739 | post_process=identity, 740 | deserializer=default_deserializer, 741 | request=( 742 | '{"jsonrpc": "2.0", "method": "subtract", ' 743 | '"params": {"subtrahend": 23, "minuend": 42}, "id": 3}' 744 | ), 745 | ) 746 | assert response == Right(SuccessResponse(19, 3)) 747 | 748 | # Second example 749 | response = dispatch_to_response_pure( 750 | methods={"subtract": subtract}, 751 | context=NOCONTEXT, 752 | validator=default_validator, 753 | post_process=identity, 754 | deserializer=default_deserializer, 755 | request=( 756 | '{"jsonrpc": "2.0", "method": "subtract", ' 757 | '"params": {"minuend": 42, "subtrahend": 23}, "id": 4}' 758 | ), 759 | ) 760 | assert response == Right(SuccessResponse(19, 4)) 761 | 762 | 763 | def test_examples_notification() -> None: 764 | response = dispatch_to_response_pure( 765 | methods={"update": lambda: None, "foobar": lambda: None}, 766 | context=NOCONTEXT, 767 | validator=default_validator, 768 | post_process=identity, 769 | deserializer=default_deserializer, 770 | request='{"jsonrpc": "2.0", "method": "update", "params": [1, 2, 3, 4, 5]}', 771 | ) 772 | assert response is None 773 | 774 | # Second example 775 | response = dispatch_to_response_pure( 776 | methods={"update": lambda: None, "foobar": lambda: None}, 777 | context=NOCONTEXT, 778 | validator=default_validator, 779 | post_process=identity, 780 | deserializer=default_deserializer, 781 | request='{"jsonrpc": "2.0", "method": "foobar"}', 782 | ) 783 | assert response is None 784 | 785 | 786 | def test_examples_invalid_json() -> None: 787 | response = dispatch_to_response_pure( 788 | methods={"ping": ping}, 789 | context=NOCONTEXT, 790 | validator=default_validator, 791 | post_process=identity, 792 | deserializer=default_deserializer, 793 | request=( 794 | '[{"jsonrpc": "2.0", "method": "sum", ' 795 | '"params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method"]' 796 | ), 797 | ) 798 | assert response == Left( 799 | ErrorResponse( 800 | ERROR_PARSE_ERROR, 801 | "Parse error", 802 | "Expecting ':' delimiter: line 1 column 96 (char 95)", 803 | None, 804 | ) 805 | ) 806 | 807 | 808 | def test_examples_empty_array() -> None: 809 | # This is an invalid JSON-RPC request, should return an error. 810 | response = dispatch_to_response_pure( 811 | request="[]", 812 | methods={"ping": ping}, 813 | context=NOCONTEXT, 814 | validator=default_validator, 815 | post_process=identity, 816 | deserializer=default_deserializer, 817 | ) 818 | assert response == Left( 819 | ErrorResponse( 820 | ERROR_INVALID_REQUEST, 821 | "Invalid request", 822 | "The request failed schema validation", 823 | None, 824 | ) 825 | ) 826 | 827 | 828 | def test_examples_invalid_jsonrpc_batch() -> None: 829 | """ 830 | We break the spec here, by not validating each request in the batch individually. 831 | The examples are expecting a batch response full of error responses. 832 | """ 833 | response = dispatch_to_response_pure( 834 | deserializer=default_deserializer, 835 | validator=default_validator, 836 | post_process=identity, 837 | context=NOCONTEXT, 838 | methods={"ping": ping}, 839 | request="[1]", 840 | ) 841 | assert response == Left( 842 | ErrorResponse( 843 | ERROR_INVALID_REQUEST, 844 | "Invalid request", 845 | "The request failed schema validation", 846 | None, 847 | ) 848 | ) 849 | 850 | 851 | def test_examples_multiple_invalid_jsonrpc() -> None: 852 | """ 853 | We break the spec here, by not validating each request in the batch individually. 854 | The examples are expecting a batch response full of error responses. 855 | """ 856 | response = dispatch_to_response_pure( 857 | deserializer=default_deserializer, 858 | validator=default_validator, 859 | post_process=identity, 860 | context=NOCONTEXT, 861 | methods={"ping": ping}, 862 | request="[1, 2, 3]", 863 | ) 864 | assert response == Left( 865 | ErrorResponse( 866 | ERROR_INVALID_REQUEST, 867 | "Invalid request", 868 | "The request failed schema validation", 869 | None, 870 | ) 871 | ) 872 | 873 | 874 | def test_examples_mixed_requests_and_notifications() -> None: 875 | methods: Dict[str, Callable[..., Any]] = { 876 | "sum": lambda *args: Success(sum(args)), 877 | "notify_hello": lambda *args: Success(19), 878 | "subtract": lambda *args: Success(args[0] - sum(args[1:])), 879 | "get_data": lambda: Success(["hello", 5]), 880 | } 881 | response = dispatch( 882 | deserializer=default_deserializer, 883 | validator=default_validator, 884 | context=NOCONTEXT, 885 | methods=methods, 886 | request="""[ 887 | {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, 888 | {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, 889 | {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, 890 | {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, 891 | {"jsonrpc": "2.0", "method": "get_data", "id": "9"} 892 | ]""", 893 | ) 894 | assert json.loads(response) == [ 895 | {"jsonrpc": "2.0", "result": 7, "id": "1"}, 896 | {"jsonrpc": "2.0", "result": 19, "id": "2"}, 897 | { 898 | "jsonrpc": "2.0", 899 | "error": {"code": -32601, "message": "Method not found", "data": "foo.get"}, 900 | "id": "5", 901 | }, 902 | {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}, 903 | ] 904 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test main.py""" 2 | from oslash.either import Right # type: ignore 3 | 4 | from jsonrpcserver.main import ( 5 | dispatch_to_json, 6 | dispatch_to_response, 7 | dispatch_to_serializable, 8 | ) 9 | from jsonrpcserver.response import SuccessResponse 10 | from jsonrpcserver.result import Result, Success 11 | 12 | # pylint: disable=missing-function-docstring 13 | 14 | 15 | def ping() -> Result: 16 | return Success("pong") 17 | 18 | 19 | def test_dispatch_to_response() -> None: 20 | assert dispatch_to_response( 21 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 22 | ) == Right(SuccessResponse("pong", 1)) 23 | 24 | 25 | def test_dispatch_to_serializable() -> None: 26 | assert dispatch_to_serializable( 27 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 28 | ) == {"jsonrpc": "2.0", "result": "pong", "id": 1} 29 | 30 | 31 | def test_dispatch_to_json() -> None: 32 | assert ( 33 | dispatch_to_json( 34 | '{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping} 35 | ) 36 | == '{"jsonrpc": "2.0", "result": "pong", "id": 1}' 37 | ) 38 | 39 | 40 | def test_dispatch_to_json_notification() -> None: 41 | assert ( 42 | dispatch_to_json('{"jsonrpc": "2.0", "method": "ping"}', {"ping": ping}) == "" 43 | ) 44 | -------------------------------------------------------------------------------- /tests/test_methods.py: -------------------------------------------------------------------------------- 1 | """Test methods.py""" 2 | from jsonrpcserver.methods import global_methods, method 3 | 4 | # pylint: disable=missing-function-docstring 5 | 6 | 7 | def test_decorator() -> None: 8 | @method 9 | def func() -> None: 10 | pass 11 | 12 | assert callable(global_methods["func"]) 13 | 14 | 15 | def test_decorator_custom_name() -> None: 16 | @method(name="new_name") 17 | def name() -> None: 18 | pass 19 | 20 | assert callable(global_methods["new_name"]) 21 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | """Test request.py""" 2 | from jsonrpcserver.request import Request 3 | 4 | # pylint: disable=missing-function-docstring 5 | 6 | 7 | def test_request() -> None: 8 | assert Request(method="foo", params=[], id=1).method == "foo" 9 | 10 | 11 | def test_request_invalid() -> None: 12 | # Should never happen, because the incoming request string is passed through the 13 | # jsonrpc schema before creating a Request 14 | pass 15 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | """Test response.py""" 2 | from unittest.mock import sentinel 3 | 4 | from oslash.either import Left, Right # type: ignore 5 | 6 | from jsonrpcserver.response import ( 7 | ErrorResponse, 8 | InvalidRequestResponse, 9 | MethodNotFoundResponse, 10 | ParseErrorResponse, 11 | ServerErrorResponse, 12 | SuccessResponse, 13 | to_serializable, 14 | ) 15 | 16 | # pylint: disable=missing-function-docstring,invalid-name,duplicate-code 17 | 18 | 19 | def test_SuccessResponse() -> None: 20 | response = SuccessResponse(sentinel.result, sentinel.id) 21 | assert response.result == sentinel.result 22 | assert response.id == sentinel.id 23 | 24 | 25 | def test_ErrorResponse() -> None: 26 | response = ErrorResponse( 27 | sentinel.code, sentinel.message, sentinel.data, sentinel.id 28 | ) 29 | assert response.code is sentinel.code 30 | assert response.message is sentinel.message 31 | assert response.data is sentinel.data 32 | assert response.id is sentinel.id 33 | 34 | 35 | def test_ParseErrorResponse() -> None: 36 | response = ParseErrorResponse(sentinel.data) 37 | assert response.code == -32700 38 | assert response.message == "Parse error" 39 | assert response.data == sentinel.data 40 | assert response.id is None 41 | 42 | 43 | def test_InvalidRequestResponse() -> None: 44 | response = InvalidRequestResponse(sentinel.data) 45 | assert response.code == -32600 46 | assert response.message == "Invalid request" 47 | assert response.data == sentinel.data 48 | assert response.id is None 49 | 50 | 51 | def test_MethodNotFoundResponse() -> None: 52 | response = MethodNotFoundResponse(sentinel.data, sentinel.id) 53 | assert response.code == -32601 54 | assert response.message == "Method not found" 55 | assert response.data == sentinel.data 56 | assert response.id == sentinel.id 57 | 58 | 59 | def test_ServerErrorResponse() -> None: 60 | response = ServerErrorResponse(sentinel.data, sentinel.id) 61 | assert response.code == -32000 62 | assert response.message == "Server error" 63 | assert response.data == sentinel.data 64 | assert response.id == sentinel.id 65 | 66 | 67 | def test_to_serializable() -> None: 68 | assert to_serializable(Right(SuccessResponse(sentinel.result, sentinel.id))) == { 69 | "jsonrpc": "2.0", 70 | "result": sentinel.result, 71 | "id": sentinel.id, 72 | } 73 | 74 | 75 | def test_to_serializable_None() -> None: 76 | assert to_serializable(None) is None 77 | 78 | 79 | def test_to_serializable_SuccessResponse() -> None: 80 | assert to_serializable(Right(SuccessResponse(sentinel.result, sentinel.id))) == { 81 | "jsonrpc": "2.0", 82 | "result": sentinel.result, 83 | "id": sentinel.id, 84 | } 85 | 86 | 87 | def test_to_serializable_ErrorResponse() -> None: 88 | assert to_serializable( 89 | Left(ErrorResponse(sentinel.code, sentinel.message, sentinel.data, sentinel.id)) 90 | ) == { 91 | "jsonrpc": "2.0", 92 | "error": { 93 | "code": sentinel.code, 94 | "message": sentinel.message, 95 | "data": sentinel.data, 96 | }, 97 | "id": sentinel.id, 98 | } 99 | 100 | 101 | def test_to_serializable_list() -> None: 102 | assert to_serializable([Right(SuccessResponse(sentinel.result, sentinel.id))]) == [ 103 | { 104 | "jsonrpc": "2.0", 105 | "result": sentinel.result, 106 | "id": sentinel.id, 107 | } 108 | ] 109 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | """Test result.py""" 2 | from unittest.mock import sentinel 3 | 4 | from oslash.either import Left, Right # type: ignore 5 | 6 | from jsonrpcserver.result import ( 7 | Error, 8 | ErrorResult, 9 | InvalidParamsResult, 10 | Success, 11 | SuccessResult, 12 | ) 13 | from jsonrpcserver.sentinels import NODATA 14 | 15 | # pylint: disable=missing-function-docstring,invalid-name 16 | 17 | 18 | def test_SuccessResult() -> None: 19 | assert SuccessResult(None).result is None 20 | 21 | 22 | def test_SuccessResult_repr() -> None: 23 | assert repr(SuccessResult(None)) == "SuccessResult(None)" 24 | 25 | 26 | def test_ErrorResult() -> None: 27 | result = ErrorResult(sentinel.code, sentinel.message) 28 | assert result.code == sentinel.code 29 | assert result.message == sentinel.message 30 | assert result.data == NODATA 31 | 32 | 33 | def test_ErrorResult_repr() -> None: 34 | assert ( 35 | repr(ErrorResult(1, "foo", None)) 36 | == "ErrorResult(code=1, message='foo', data=None)" 37 | ) 38 | 39 | 40 | def test_ErrorResult_with_data() -> None: 41 | result = ErrorResult(sentinel.code, sentinel.message, sentinel.data) 42 | assert result.code == sentinel.code 43 | assert result.message == sentinel.message 44 | assert result.data == sentinel.data 45 | 46 | 47 | def test_InvalidParamsResult() -> None: 48 | result = InvalidParamsResult(sentinel.data) 49 | assert result.code == -32602 50 | assert result.message == "Invalid params" 51 | assert result.data == sentinel.data 52 | 53 | 54 | def test_InvalidParamsResult_with_data() -> None: 55 | result = InvalidParamsResult(sentinel.data) 56 | assert result.code == -32602 57 | assert result.message == "Invalid params" 58 | assert result.data == sentinel.data 59 | 60 | 61 | def test_Success() -> None: 62 | assert Success() == Right(SuccessResult(None)) 63 | 64 | 65 | def test_Error() -> None: 66 | assert Error(1, "foo", None) == Left(ErrorResult(1, "foo", None)) 67 | -------------------------------------------------------------------------------- /tests/test_sentinels.py: -------------------------------------------------------------------------------- 1 | """Test sentinels.py""" 2 | from jsonrpcserver.sentinels import Sentinel 3 | 4 | # pylint: disable=missing-function-docstring 5 | 6 | 7 | def test_sentinel() -> None: 8 | assert repr(Sentinel("foo")) == "" 9 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | """Test server.py""" 2 | from unittest.mock import Mock, patch 3 | 4 | from jsonrpcserver.server import serve 5 | 6 | # pylint: disable=missing-function-docstring 7 | 8 | 9 | @patch("jsonrpcserver.server.HTTPServer") 10 | def test_serve(*_: Mock) -> None: 11 | serve() 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py38,py39,py310 8 | 9 | [testenv] 10 | setenv = PYTHONDONTWRITEBYTECODE=1 11 | deps = 12 | pytest 13 | pytest-asyncio 14 | commands = pytest tests 15 | install_command=pip install --trusted-host=pypi.org --trusted-host=files.pythonhosted.org {opts} {packages} 16 | --------------------------------------------------------------------------------