├── .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 |
3 |
4 |
5 |
6 | Process incoming JSON-RPC requests in Python
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 | 
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 |
--------------------------------------------------------------------------------