diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b9df452..58b7fe8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,6 +13,9 @@ body: id: version attributes: label: jsonrpcserver version + description: >- + Run `pip show jsonrpcserver` to find it. From 5.0.10 you can also + print `jsonrpcserver.__version__`. placeholder: "5.0.9" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 719b40b..0484ccf 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,11 @@ blank_issues_enabled: true contact_links: + - name: Ask a question + url: https://github.com/bensynapse/jsonrpcserver/discussions/categories/q-a + about: Questions about using jsonrpcserver are answered in Discussions. - name: Report a security problem url: https://github.com/bensynapse/jsonrpcserver/security/advisories/new about: Please report security problems privately, not in a public issue. - name: Documentation url: https://bensynapse.github.io/jsonrpcserver/ - about: Usage and examples. + about: Guides, framework examples, the API reference and the FAQ. diff --git a/.github/scripts/check_site.py b/.github/scripts/check_site.py new file mode 100644 index 0000000..dfdd15d --- /dev/null +++ b/.github/scripts/check_site.py @@ -0,0 +1,194 @@ +"""Check the built docs site in a real browser. + +Usage: python .github/scripts/check_site.py SITE_DIR AXE_JS + +Serves SITE_DIR at http://localhost:8765/jsonrpcserver/, the same path as on +GitHub Pages, and opens every page in the sitemap, plus the 404 page, in light +and dark mode at desktop and phone widths. It fails if: + +- axe-core finds a serious or critical accessibility problem, +- a page scrolls sideways, has a JavaScript error, or has no visible h1, + description or canonical link, +- a link to another page of the site, or to an anchor on it, is broken. + +Needs playwright (pip install playwright, then playwright install chromium). +""" + +import functools +import re +import sys +import threading +import urllib.error +import urllib.request +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, List, Set, Tuple +from urllib.parse import urldefrag + +from playwright.sync_api import Page, sync_playwright + +PORT = 8765 +BASE = f"http://localhost:{PORT}/jsonrpcserver/" +LIVE = "https://bensynapse.github.io/jsonrpcserver/" +VIEWPORTS = {"desktop": (1280, 900), "phone": (375, 812)} + + +class Handler(SimpleHTTPRequestHandler): + """Serve the site under /jsonrpcserver/, with 404.html for missing pages.""" + + def translate_path(self, path: str) -> str: + path = path.split("?", 1)[0].split("#", 1)[0] + if not path.startswith("/jsonrpcserver/"): + return "" + return super().translate_path(path[len("/jsonrpcserver") :]) + + def send_error(self, code: int, message: Any = None, explain: Any = None) -> None: + if code != 404: + super().send_error(code, message, explain) + return + body = (Path(self.directory) / "404.html").read_bytes() + self.send_response(404) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: + pass + + +class Server(ThreadingHTTPServer): + def handle_error(self, request: Any, client_address: Any) -> None: + # The browser often drops a connection it no longer needs. + if not isinstance(sys.exc_info()[1], ConnectionError): + super().handle_error(request, client_address) + + +def record(errors: List[str], exc: Exception) -> None: + errors.append(str(exc)) + + +def page_info(page: Page) -> Dict[str, Any]: + return page.evaluate( + """() => { + const q = (s) => document.querySelector(s); + const h1 = [...document.querySelectorAll(".md-content h1")] + .filter((h) => h.offsetParent !== null); + const doc = document.documentElement; + return { + h1: h1.length, + description: (q('meta[name="description"]') || {}).content || "", + canonical: (q('link[rel="canonical"]') || {}).href || "", + overflow: doc.scrollWidth > doc.clientWidth, + links: [...document.querySelectorAll("a[href]")].map((a) => a.href), + ids: [...document.querySelectorAll("[id]")].map((e) => e.id), + }; + }""" + ) + + +def axe_problems(page: Page, axe: str) -> List[str]: + page.add_script_tag(content=axe) + violations: List[Dict[str, Any]] = page.evaluate( + """async () => { + const result = await axe.run(document, { + runOnly: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"], + }); + return result.violations.map((v) => ({ + id: v.id, impact: v.impact, nodes: v.nodes.map((n) => n.target.join(" ")), + })); + }""" + ) + return [ + f"{v['id']} ({v['impact']}): {', '.join(v['nodes'][:3])}" + for v in violations + if v["impact"] in ("serious", "critical") + ] + + +def main(site: Path, axe: str) -> int: + handler = functools.partial(Handler, directory=str(site)) + server = Server(("localhost", PORT), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + sitemap = (site / "sitemap.xml").read_text() + urls = re.findall(r"(.*?)", sitemap) + pages = [url.replace(LIVE, BASE) for url in urls] + problems: List[str] = [] + links: Set[str] = set() + ids: Dict[str, Set[str]] = {} + with sync_playwright() as playwright: + browser = playwright.chromium.launch() + for scheme in ("light", "dark"): + for name, (width, height) in VIEWPORTS.items(): + context = browser.new_context( + viewport={"width": width, "height": height}, + color_scheme=scheme, + is_mobile=name == "phone", + ) + page = context.new_page() + errors: List[str] = [] + page.on("pageerror", functools.partial(record, errors)) + for url in [*pages, BASE + "no-such-page/"]: + errors.clear() + response = page.goto(url, wait_until="networkidle") + where = f"{url} ({scheme}, {name})" + expected = 404 if url.endswith("no-such-page/") else 200 + status = response.status if response else None + if status != expected: + problems.append(f"{where}: status {status}") + info = page_info(page) + if info["h1"] != 1: + problems.append(f"{where}: {info['h1']} visible h1") + if not info["description"]: + problems.append(f"{where}: no description") + if expected == 200 and not info["canonical"]: + problems.append(f"{where}: no canonical link") + if info["overflow"]: + problems.append(f"{where}: the page scrolls sideways") + problems.extend(f"{where}: JavaScript error {e}" for e in errors) + problems.extend(f"{where}: {p}" for p in axe_problems(page, axe)) + links.update(info["links"]) + ids[url] = set(info["ids"]) + context.close() + browser.close() + problems.extend(check_links(links, ids)) + server.shutdown() + for problem in problems: + print(problem) + print(f"{len(pages) + 1} pages, 4 views each, {len(problems)} problems") + return 1 if problems else 0 + + +def check_links(links: Set[str], ids: Dict[str, Set[str]]) -> List[str]: + problems: List[str] = [] + status: Dict[str, int] = {} + for link in sorted(links): + # The banner and the 404 page link to the live site's address. + url, fragment = urldefrag(link.replace(LIVE, BASE)) + if not url.startswith(BASE): + continue + if url not in status: + status[url] = fetch_status(url) + if status[url] != 200: + problems.append(f"broken link: {link} ({status[url]})") + elif fragment and url in ids and fragment not in ids[url]: + problems.append(f"broken anchor: {link}") + return problems + + +def fetch_status(url: str) -> int: + try: + with urllib.request.urlopen(url, timeout=10) as response: + return int(response.status) + except urllib.error.HTTPError as exc: + return exc.code + + +def parse_args(argv: List[str]) -> Tuple[Path, str]: + if len(argv) != 2: + raise SystemExit(__doc__) + return Path(argv[0]), Path(argv[1]).read_text() + + +if __name__ == "__main__": + sys.exit(main(*parse_args(sys.argv[1:]))) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a011fd9..07a673c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,9 @@ jobs: - name: No links to hijacked domains anywhere in the repo run: | # The second pattern is the previous author's blog, which now redirects to spam. - if git grep -n -I -i -E 'jsonrpc(client|server)\.com|composed\.blog' -- ':!.github/scripts/check_metadata.py'; then + # The FAQ names the old domains, as plain text, so readers recognise them. + # tests/test_docs.py checks that they're never links there. + if git grep -n -I -i -E 'jsonrpc(client|server)\.com|composed\.blog' -- ':!.github/scripts/check_metadata.py' ':!docs/faq.md'; then echo "::error::Remove the links above. Those domains no longer belong to the project." exit 1 fi @@ -89,16 +91,41 @@ jobs: - run: mkdocs build --strict - name: Examples in the README and docs, with every optional library installed run: | - for f in README.md docs/*.md; do + for f in README.md $(find docs -name '*.md' | sort); do python tests/doc_examples.py "$f" done - name: Start each example server and send it requests run: python docs/examples/check_examples.py + site: + # The built docs in a browser: accessibility (axe-core), links and anchors, + # and pages that scroll sideways. See .github/scripts/check_site.py. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - run: python -m pip install -r requirements/docs.txt -r requirements/site.txt + - run: python -m playwright install --with-deps chromium + - run: mkdocs build --strict + - name: Download axe-core and check its hash + env: + AXE_VERSION: "4.14.0" + AXE_SHA512: "9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==" + run: | + curl -sSfL -o axe.tgz "https://registry.npmjs.org/axe-core/-/axe-core-$AXE_VERSION.tgz" + echo "$AXE_SHA512" | base64 -d > expected.bin + openssl dgst -sha512 -binary axe.tgz | cmp - expected.bin + tar -xzf axe.tgz package/axe.min.js + - run: python .github/scripts/check_site.py site package/axe.min.js + ci-ok: name: CI OK if: always() - needs: [test, lint, package, docs] + needs: [test, lint, package, docs, site] runs-on: ubuntu-24.04 steps: - name: All jobs passed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88226b6..293fcf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,8 +32,22 @@ jobs: echo "Tag $GITHUB_REF_NAME does not match __version__ $version" exit 1 fi - - name: CHANGELOG must have an entry for this version - run: grep -q "^## ${GITHUB_REF_NAME#v}\b" CHANGELOG.md + - name: CHANGELOG entry must be dated, and the docs must not call it unreleased + run: | + version="${GITHUB_REF_NAME#v}" + pattern="${version//./\\.}" + if ! grep -qE "^## ${pattern} \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$" CHANGELOG.md; then + echo "CHANGELOG.md needs a heading like: ## $version (YYYY-MM-DD). See RELEASING.md." + exit 1 + fi + if grep -qE "^ *unreleased: \"?${pattern}\"?$" mkdocs.yml; then + echo "mkdocs.yml still marks $version as unreleased. See RELEASING.md." + exit 1 + fi + if grep -q "isn't released yet" README.md; then + echo "README.md still says a version isn't released yet. See RELEASING.md." + exit 1 + fi - run: python -m build - run: python -m twine check --strict dist/* - run: check-wheel-contents dist/*.whl diff --git a/CHANGELOG.md b/CHANGELOG.md index 50164a9..85087fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ -# jsonrpcserver Change Log +# Changelog -## 5.0.10 +## 5.0.10 (not released yet) The first release since the project moved to [bensynapse/jsonrpcserver](https://github.com/bensynapse/jsonrpcserver). It fixes a security problem, so please upgrade. Code that works with 5.0.9 keeps -working, apart from the security fix and the "Behaviour changes" below. +working, apart from the security fix and the "Behaviour changes" below. The +[migration guide](https://bensynapse.github.io/jsonrpcserver/migration/#from-509-to-5010) +lists what you might notice. Until it's on PyPI, the +[Security page](https://bensynapse.github.io/jsonrpcserver/security/#if-you-are-on-509) +shows how to protect a 5.0.9 server. ### Security @@ -22,9 +26,10 @@ The response now leaves `data` out: ``` The same applies to the -32000 "Server error" response for errors inside -jsonrpcserver itself. The exception and its traceback are still logged, through -the `jsonrpcserver.dispatcher` and `jsonrpcserver.async_dispatcher` loggers, so -you can find them in your server logs. +jsonrpcserver itself. The exception and its traceback are still logged, on the +`jsonrpcserver` logger, so you can find them in your server logs. The +[logging section](https://bensynapse.github.io/jsonrpcserver/errors/#logging) +of the docs lists its child loggers. To get the old behaviour back while developing, pass `debug=True` to `dispatch`, `async_dispatch` or any of the other dispatch functions. Don't turn @@ -65,7 +70,7 @@ the JSON-RPC spec says (#291). `[1, {"jsonrpc": "2.0", "method": "ping", "id": error and `ping` runs. As part of that change, a custom `validator` is now called once for each -request in a batch, with that request's dict. Before, it was called once with +request in a batch, with just that request. Before, it was called once with the whole list. Some validators enforced a rule about the batch as a whole, such as a size limit or refusing batches. Those no longer see the list, so the rule silently stops working. Use `max_batch_size` for a size limit. Validators @@ -108,6 +113,13 @@ it requests, and runs every code example in the docs. The websockets example uses the current `websockets.asyncio` API (#287). A new Security page covers the settings to check before exposing a server. +The site has an API reference built from the docstrings and a migration +guide from 4.x. New pages cover errors and logging, notifications and +batches, context, validation, typing, testing and threads. Each framework has its own +page, and every example sets `max_batch_size` and a request size limit. The +examples listen on `localhost:8000`, where the jsonrpcclient examples +connect. + ### Packaging - A wheel is published as well as the source distribution, so installs no @@ -192,18 +204,24 @@ work in 5.x but give a `DeprecationWarning`, and will be removed in 6.0. - Add to FAQ. -## 5.0.3 +## 5.0.3 (Aug 31, 2021) - Update readme and documentation. - Internal function `compose` has been replaced with a better one. -## 5.0.2 +## 5.0.2 (Aug 18, 2021) - Update readme and setup.py, minor adjustments. +## 5.0.1 (Aug 18, 2021) + +No changelog entry was written for this release. See the `5.0.1` tag. + ## 5.0.0 (Aug 16, 2021) -A complete rebuild, with a few important usage changes. +A complete rebuild, with a few important usage changes. The +[migration guide](https://bensynapse.github.io/jsonrpcserver/migration/#from-4x-to-5x) +shows how to update 4.x code. - Methods must now return a Result (Success or Error). - The dispatch function now returns a string. @@ -333,7 +351,7 @@ _The 4.x releases will support Python 3.6+ only._ - Pass some context data through dispatch to the methods. - Fix not calling notifications in batch requests. -## 3.4.3 (Jul 13, 2017) +## 3.4.4 (Jul 13, 2017) - Fix AttributeError on batch responses ## 3.4.3 (Jul 12, 2017) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e79632..14e2934 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,10 +36,19 @@ mkdocs serve ``` `tests/test_docs.py` runs every Python block in the README and docs, so keep -the shown output accurate. The framework examples are files in -`docs/examples/`. To run them against real servers, install -`requirements/examples.txt` and run `python docs/examples/check_examples.py`. -They listen on port 5000, so free it first. +the shown output accurate. `tests/doc_examples.py` explains the markers that +skip a block or start the quickstart server for it. Blocks that need a +framework are skipped unless it's installed, so to run them all, install +`requirements/examples.txt` first, as CI's docs job does. + +The framework examples are files in `docs/examples/`. To start each one and +send it real requests, install `requirements/examples.txt` and run +`python docs/examples/check_examples.py`. It also needs curl. The examples +listen on `localhost:8000`, so free that port first. + +The API reference is built from the docstrings with mkdocstrings. Use Google +style, and add every new public name to `docs/reference.md`. A test checks +that every name in `__all__` is there. ## Pull requests diff --git a/README.md b/README.md index 0edf0cc..b55fbfb 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,124 @@

- PyPI + PyPI version CI - Python versions - Downloads - License + Python 3.8 to 3.14 + Downloads per month + License: MIT

- Jsonrpcserver Logo + jsonrpcserver

- Process incoming JSON-RPC requests in Python + Process incoming JSON-RPC 2.0 requests in Python

Documentation | + API reference | Examples | - Changelog + Changelog | + Migration

-https://github.com/user-attachments/assets/94fb4f04-a5f1-41ca-84dd-7e18b87990e0 - -## Installation +jsonrpcserver takes a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) +request, calls your Python function and gives you the response to send back. +It leaves the networking to you, so it fits into whatever server you already +have. + +## Features + +- Works with any framework or transport. The docs have tested examples for + http.server, Flask, Werkzeug, Django, FastAPI, aiohttp, Sanic, Tornado, + websockets, ZeroMQ and Socket.IO. +- Sync and async: `dispatch` and `async_dispatch`. With `async_dispatch`, the + requests in a batch run concurrently. +- Follows the spec for batches, notifications and errors, and checks each + request's params against your function's signature. +- Keeps exception messages out of responses and in your logs, and limits + batch size with `max_batch_size` (from 5.0.10). +- Typed: ships `py.typed`, and `@method` keeps your functions' signatures for + mypy and pyright. +- Safe to call from several threads, and tested on Python 3.8 to 3.14, + including free-threaded 3.14t. + +## Install ```sh pip install jsonrpcserver ``` -It supports Python 3.8 and later. +The latest release on PyPI is 5.0.9. This README and the documentation +describe 5.0.10, which isn't released yet. 5.0.9 sends exception messages to +clients. The +[Security page](https://bensynapse.github.io/jsonrpcserver/security/#if-you-are-on-509) +shows how to stop that, and the +[changelog](https://bensynapse.github.io/jsonrpcserver/changelog/) lists the +other differences. + +## Quickstart -## Usage +Save this as `server.py` and run it with `python server.py`: + ```python -from jsonrpcserver import Result, Success, dispatch, method +from jsonrpcserver import Result, Success, method, serve @method def ping() -> Result: return Success("pong") + + +if __name__ == "__main__": + serve("localhost", 8000) ``` -```python ->>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') -'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ ``` -jsonrpcserver doesn't listen on a port itself. The -[examples](https://bensynapse.github.io/jsonrpcserver/examples/) show it with -Flask, FastAPI, Django, aiohttp, websockets, ZeroMQ and more. +Output: -Before you expose a server to the internet, read the -[security notes](https://bensynapse.github.io/jsonrpcserver/security/). +```text +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +`serve()` is a small server for trying things out. In your own framework, +pass the request body to `dispatch` and send back the string it returns. The +[examples](https://bensynapse.github.io/jsonrpcserver/examples/) show how. + +## Security + +Before you put a server on the internet, pass `max_batch_size` to `dispatch` +and limit the request body size in your framework. Upgrade to 5.0.10 when +it's out. The +[Security page](https://bensynapse.github.io/jsonrpcserver/security/) +explains why. ## Documentation -Full documentation is at -[bensynapse.github.io/jsonrpcserver](https://bensynapse.github.io/jsonrpcserver/). +- [Documentation](https://bensynapse.github.io/jsonrpcserver/): guides, + framework examples and the + [API reference](https://bensynapse.github.io/jsonrpcserver/reference/). +- [Migrating from 4.x](https://bensynapse.github.io/jsonrpcserver/migration/): + methods must now return `Success(value)`. A 4.x method that returns a plain + value still runs, but the client gets an Internal error. +- [Contributing](https://github.com/bensynapse/jsonrpcserver/blob/main/CONTRIBUTING.md) + and the + [security policy](https://github.com/bensynapse/jsonrpcserver/blob/main/SECURITY.md). +- [License](https://github.com/bensynapse/jsonrpcserver/blob/main/LICENSE): MIT. ## See also -- [jsonrpcclient](https://github.com/bensynapse/jsonrpcclient): create JSON-RPC requests and parse responses in Python +[jsonrpcclient](https://bensynapse.github.io/jsonrpcclient/) +([GitHub](https://github.com/bensynapse/jsonrpcclient)) creates JSON-RPC +requests and parses the responses in Python. It's the client-side companion +to this library, and its quickstart talks to the server above. + +## Credits + +Created by Beau Barker. Maintained by [Synapse Research](https://synapsereality.io). diff --git a/RELEASING.md b/RELEASING.md index a5165d3..e41fe08 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,9 +29,23 @@ Settings → Environments → pypi. ## Each release -1. Merge a pull request that sets `__version__` in `jsonrpcserver/__init__.py` - and adds a `## ` section to CHANGELOG.md. The release workflow - fails if either is missing or they don't match the tag. +1. Merge a pull request that gets the version ready: + - `__version__` in `jsonrpcserver/__init__.py` is the new version. + - Its CHANGELOG.md heading has the release date instead of "not released + yet": `## 5.0.10 (2026-10-20)`. + - The `unreleased` and `pypi_version` lines under `extra` in mkdocs.yml are + deleted. They show the "not on PyPI yet" banner on every docs page. + Merging this redeploys the docs, so merge it just before you tag. + - The paragraph under "Install" in README.md that says the version isn't + released yet is deleted. The README becomes the PyPI page. + - For 5.0.10 only: shorten the "If you are on 5.0.9" section of + `docs/security.md` to a note for people who can't upgrade. The "when it's + out" wording in README.md and SECURITY.md can go too. `grep -rn "when it's out\|isn't on PyPI" README.md + SECURITY.md docs` finds them. + + The release workflow checks the first three. It stops if the version and + tag don't match or the CHANGELOG heading has no date. It also stops if + mkdocs.yml or README.md still say the version isn't released. 2. Tag the merge commit on main and push the tag: ```sh @@ -50,6 +64,15 @@ Settings → Environments → pypi. The release should have two files, a `.tar.gz` and a `.whl`. +5. If the release fixes a security problem, publish a repository security + advisory for it, so Dependabot, `pip-audit` and OSV warn people on older + versions. In the Security tab, choose "New draft security advisory". Set + the affected versions (for 5.0.10: `>= 5.0.0, <= 5.0.9`) and the patched + version. + Pick the CWE, link the docs page that explains it, and publish. For the + 5.0.10 fix, the CWE is CWE-209, information exposure through an error + message. + ## If the upload fails - `invalid-publisher`: the PyPI publisher settings above don't match. Check @@ -65,4 +88,6 @@ whose version reached PyPI. ## Docs The docs site deploys itself from main through `.github/workflows/docs.yml`, -so it changes when the docs change, not at release time. +so it changes when the docs change, not at release time. While main is ahead +of PyPI, the `unreleased` setting in mkdocs.yml shows a banner that says so. +The docs also mark features with "New in" or "Changed in" notes. diff --git a/SECURITY.md b/SECURITY.md index 4ccaa45..80bb104 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,10 +13,11 @@ problem. We'll reply within a week. Fixes go into the latest 5.x release. Older releases don't get updates. -Versions before 5.0.10 send the message of any uncaught exception in a method -to the client. Please upgrade. The -[security notes](https://bensynapse.github.io/jsonrpcserver/security/) in the -docs explain this and the other settings to check before exposing a server. +Versions 5.0.0 to 5.0.9 send the message of any uncaught exception in a +method to the client. Please upgrade when 5.0.10 is on PyPI. Until then, the +[security notes](https://bensynapse.github.io/jsonrpcserver/security/#if-you-are-on-509) +in the docs show how to protect a 5.0.9 server. They also cover the other +settings to check before exposing a server. ## Old domains diff --git a/docs/assets/a11y.js b/docs/assets/a11y.js new file mode 100644 index 0000000..a752b79 --- /dev/null +++ b/docs/assets/a11y.js @@ -0,0 +1,27 @@ +// Small accessibility fixes for Material for MkDocs defaults. They run after +// Material has rendered each page, including after instant navigation. +document$.subscribe(() => { + // The search overlay is a dialog, so it needs a name. + const search = document.querySelector(".md-search"); + if (search && !search.hasAttribute("aria-label")) { + search.setAttribute("aria-label", "Search"); + } + document.querySelectorAll(".md-code__nav").forEach((nav, i) => { + // Each code block's toolbar is a nav landmark; give each a distinct name. + nav.setAttribute("aria-label", `Code block ${i + 1} actions`); + }); + document.querySelectorAll(".highlight pre > code").forEach((code) => { + // A code block that scrolls sideways must be reachable with the keyboard. + if (code.scrollWidth > code.clientWidth) { + code.setAttribute("tabindex", "0"); + } + }); + document.querySelectorAll(".md-typeset__scrollwrap").forEach((wrap, i) => { + // So must a table that's wider than the screen. + if (wrap.scrollWidth > wrap.clientWidth) { + wrap.setAttribute("tabindex", "0"); + wrap.setAttribute("role", "region"); + wrap.setAttribute("aria-label", `Table ${i + 1}, scrolls sideways`); + } + }); +}); diff --git a/docs/assets/apple-touch-icon.png b/docs/assets/apple-touch-icon.png new file mode 100644 index 0000000..fac0945 Binary files /dev/null and b/docs/assets/apple-touch-icon.png differ diff --git a/docs/assets/copy-without-prompts.js b/docs/assets/copy-without-prompts.js new file mode 100644 index 0000000..eeecaff --- /dev/null +++ b/docs/assets/copy-without-prompts.js @@ -0,0 +1,19 @@ +// The copy button on an interactive session (a pycon block) copies only the +// code: the ">>> " and "... " prompts are removed and output lines are left +// out, so the result can be pasted into a file or a terminal. +document.addEventListener( + "click", + (event) => { + const button = event.target.closest("[data-clipboard-target]"); + if (!button) return; + const target = document.querySelector(button.dataset.clipboardTarget); + if (!target || !target.closest(".language-pycon")) return; + const code = target.textContent + .split("\n") + .filter((line) => /^(>>>|\.\.\.)( |$)/.test(line)) + .map((line) => line.slice(4)) + .join("\n"); + button.setAttribute("data-clipboard-text", code + "\n"); + }, + true, +); diff --git a/docs/assets/extra.css b/docs/assets/extra.css index 26e3b96..dc7db7f 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -1,106 +1,117 @@ +/* System fonts instead of web fonts (theme.font is false in mkdocs.yml). */ :root { - /* ✅ Fonts */ - --md-text-font: -apple-system, BlinkMacSystemFont, "Segoe UI Adjusted", - "Segoe UI", "Liberation Sans", sans-serif; + --md-text-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", + Roboto, sans-serif; --md-code-font: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", Menlo, Consolas, monospace; +} - /* ✅ Light Mode Text Colors */ +/* Code colours with at least 4.5:1 contrast on the code background. Material's + defaults for comments, output and a few token types fall just short. */ +[data-md-color-scheme="default"] { --md-default-fg-color: #111; + --md-code-hl-comment-color: #5e5e5e; + --md-code-hl-generic-color: #5e5e5e; + --md-code-hl-operator-color: #5e5e5e; + --md-code-hl-variable-color: #5e5e5e; +} + +/* Pink is this site's colour (jsonrpcclient's is indigo). Material's pink, + #e92063, gives white text 4.3:1 and fails WCAG AA, so use a darker one: + #c2185b is 5.9:1 against white either way round. */ +[data-md-color-primary="pink"] { + --md-primary-fg-color: #c2185b; + --md-primary-fg-color--light: #d81b60; + --md-primary-fg-color--dark: #ad1457; +} + +[data-md-color-accent="pink"] { + --md-accent-fg-color: #ad1457; + --md-accent-fg-color--transparent: rgba(173, 20, 87, 0.1); +} + +/* Material sets the link colour per scheme and primary colour, so match those + selectors. */ +[data-md-color-scheme="default"][data-md-color-primary="pink"] { + --md-typeset-a-color: #c2185b; +} + +[data-md-color-scheme="slate"][data-md-color-primary="pink"] { + --md-typeset-a-color: #f48fb1; +} + +[data-md-color-scheme="slate"][data-md-color-accent="pink"] { + --md-accent-fg-color: #f8bbd0; + --md-accent-fg-color--transparent: rgba(248, 187, 208, 0.1); } [data-md-color-scheme="slate"] { - /* ✅ Dark Mode Text Colors */ --md-default-fg-color: #e9e9e9; + --md-code-hl-comment-color: #a8a8b3; + --md-code-hl-generic-color: #a8a8b3; + --md-code-hl-number-color: #f39287; + --md-code-hl-constant-color: #ab9ff0; +} + +/* The repository's star and fork counts in the header are faded by default, + which leaves them below 4.5:1 on the pink header. */ +.md-source__facts { + opacity: 1; +} + +/* The "not on PyPI yet" banner sits on a dark background in both schemes. */ +.md-banner a, +.md-banner a:hover, +.md-banner a:focus { + color: #fff; + text-decoration: underline; +} + +.md-banner code { + background-color: transparent; + color: inherit; } -/* Fix a horizontal scroll isue */ -.md-typeset * { - margin-left: 0 !important; - margin-right: 0 !important; +/* The footer's copyright text, which is too faint by default. */ +.md-footer-meta { + --md-footer-fg-color--lighter: hsla(0, 0%, 100%, 0.75); +} + +.md-copyright a { + text-decoration: underline; } .md-typeset { - margin-left: 0 !important; - margin-right: 0 !important; - - p, - h1, - h2, - h3, - h4, - h5, - h6 { - margin: 0; - padding: 0; - padding-left: 20px; - padding-right: 20px; - } - - /* Change header fonts */ - h1, h2, h3, h4, h5, h6 { - font-weight: 700; - font-style: normal; - letter-spacing: -0.5px; - line-height: 140%; - } - - h1 { - color: var(--md-default-fg-color); - font-size: 1.2rem; - margin-bottom: 1em; - } - - h2 { - font-size: 1rem; - } - - h3 { - font-size: 0.9rem; - } - - p, ul, ol, pre { - font-size: 17px; - margin-top: 1em; - margin-bottom: 1em; - } - - ul, ol { - margin-left: 3rem; - padding-left: 3rem; - } - - blockquote { - border-left: 0 !important; - border-radius: 0; - margin: 0; - margin-bottom: 1em; - padding: 1px; - background-color: rgba(255, 87, 34, 0.05); - - [data-md-color-scheme="slate"] { - background-color: rgba(255, 204, 0, 0.1); - } - } - - pre { - line-height: 150%; - } - pre > code { - padding-left: 20px; - } -} - -.md-content__inner { - /* Target only the page title (first H1 in the article) */ - .md-typeset { - h1:first-of-type { - padding-left: 20px; - } - } -} - -article { - padding-bottom: 3rem; + font-size: 0.85rem; +} + +.md-typeset h1, +.md-typeset h2, +.md-typeset h3 { + font-weight: 700; + letter-spacing: -0.01em; } +.md-typeset h1 { + color: var(--md-default-fg-color); +} + +/* Underline links in running text, so they don't rely on colour alone + (WCAG 1.4.1). Navigation, buttons and permalinks keep their own style. */ +.md-typeset p a, +.md-typeset li a, +.md-typeset td a { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.md-typeset blockquote { + border-left: 0.2rem solid rgb(255, 87, 34); + background-color: rgba(255, 87, 34, 0.05); + color: var(--md-default-fg-color); +} + +[data-md-color-scheme="slate"] .md-typeset blockquote { + border-left-color: rgb(255, 204, 0); + background-color: rgba(255, 204, 0, 0.1); +} diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000..0170a2d Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/social-card.png b/docs/assets/social-card.png new file mode 100644 index 0000000..f6d8bb3 Binary files /dev/null and b/docs/assets/social-card.png differ diff --git a/docs/async.md b/docs/async.md index c553a1a..e573443 100644 --- a/docs/async.md +++ b/docs/async.md @@ -1,3 +1,7 @@ +--- +description: Use jsonrpcserver in asyncio servers with async_dispatch. Async and plain methods, concurrent batches, notifications, and the async versions of every dispatch function. +--- + # Async For asyncio servers, use `async_dispatch`. Methods can be `async`, and their @@ -14,7 +18,7 @@ async def ping() -> Result: return Success("pong") ``` -```python +```pycon >>> asyncio.run(async_dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}')) '{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` @@ -26,10 +30,34 @@ In a real server you're already inside a coroutine, so write `async_dispatch_to_serializable` and `async_dispatch_to_response` are the async versions of the other two functions. -Plain functions work as methods too, but they run on the event loop, so a slow -one holds up everything else. Async methods don't work with the synchronous -`dispatch`. The client gets an Internal error, and the log says to use -`async_dispatch`. +## Plain methods + +Plain functions work as methods too: + +```python +@method +def add(a: int, b: int) -> Result: + return Success(a + b) +``` + +```pycon +>>> asyncio.run( +... async_dispatch('{"jsonrpc": "2.0", "method": "add", "params": [2, 3], "id": 1}') +... ) +'{"jsonrpc": "2.0", "result": 5, "id": 1}' +``` + +They run on the event loop, so a slow one holds up every other request. Make +slow methods `async`, or move the work to a thread with +`await asyncio.to_thread(...)`. + +!!! info "New in 5.0.10" + In 5.0.9, a plain method called through `async_dispatch` gives an + Internal error. There, make every method `async def`. + +It doesn't work the other way round. Async methods don't work with the +synchronous `dispatch`. The client gets an Internal error, and the log says to +use `async_dispatch`. ## Batches run concurrently @@ -50,7 +78,7 @@ async def wait() -> Result: batch = json.dumps([{"jsonrpc": "2.0", "method": "wait", "id": n} for n in range(10)]) ``` -```python +```pycon >>> start = time.monotonic() >>> responses = json.loads(asyncio.run(async_dispatch(batch))) >>> len(responses) @@ -59,15 +87,18 @@ batch = json.dumps([{"jsonrpc": "2.0", "method": "wait", "id": n} for n in range True ``` -Every request in a batch runs at once, so set `max_batch_size` if clients -can send big batches. See [Security](security.md). +The responses still come back in the same order as the requests. + +Every request in a batch runs at once, so a big batch can start thousands of +tasks. Set `max_batch_size` if clients can send big batches. See +[Security](security.md#limit-batch-size). ## Notifications A notification is a request with no `id`. The spec says not to respond to it, so `async_dispatch` gives an empty string: -```python +```pycon >>> asyncio.run(async_dispatch('{"jsonrpc": "2.0", "method": "ping"}')) '' ``` @@ -76,7 +107,13 @@ With HTTP you have to send something, so send an empty body with status 204. With websockets or a message queue, you can skip sending: ```python -async def handle(request: str, send) -> None: +from typing import Awaitable, Callable + + +async def handle(request: str, send: Callable[[str], Awaitable[None]]) -> None: if response := await async_dispatch(request): await send(response) ``` + +The [framework examples](examples.md) include aiohttp, FastAPI, Sanic, +Tornado, websockets and ZeroMQ's asyncio API. diff --git a/docs/batches.md b/docs/batches.md new file mode 100644 index 0000000..8b94e77 --- /dev/null +++ b/docs/batches.md @@ -0,0 +1,135 @@ +--- +description: How jsonrpcserver handles JSON-RPC notifications and batches. When there's nothing to send, HTTP 204, response order, invalid members, empty batches and max_batch_size. +--- + +# Notifications and batches + +## Notifications + +A notification is a request with no `id`. The client doesn't want an answer, +and the spec says the server must not send one. The method still runs, and +`dispatch` gives an empty string: + +```python +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") +``` + +```pycon +>>> dispatch('{"jsonrpc": "2.0", "method": "ping"}') +'' +``` + +What to do with the empty string depends on the transport: + +- **HTTP** always sends a response, so send status 204 No Content with no + body. +- **websockets** and most message queues: send nothing. +- **ZeroMQ REQ/REP** sockets must answer every message, so send the empty + string. + +The [framework examples](examples.md) each do the right thing. + +!!! note + A notification gets no response even when it fails, so the client never + hears about the error. That's how the spec wants it. If the method raises + an exception, it's logged (see [Errors and logging](errors.md)). Other + errors, such as an unknown method or params that don't fit, aren't + recorded anywhere. + +## Batches + +A batch is a JSON array of requests. Each one is run, and the responses come +back as an array: + +```pycon +>>> dispatch( +... '[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]' +... ) +'[{"jsonrpc": "2.0", "result": "pong", "id": 1}, {"jsonrpc": "2.0", "result": "pong", "id": 2}]' +``` + +jsonrpcserver sends the responses in the same order as the requests. The spec +doesn't require that, so clients should match responses to requests by `id`. + +`dispatch` runs the requests one after another. `async_dispatch` runs them all +at once. See [Async](async.md#batches-run-concurrently). + +### Notifications in a batch + +Notifications in a batch run, but get no response. If every request in a +batch is a notification, there's nothing to send, and `dispatch` gives an +empty string: + +```pycon +>>> dispatch( +... '[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping"}]' +... ) +'[{"jsonrpc": "2.0", "result": "pong", "id": 1}]' +>>> dispatch('[{"jsonrpc": "2.0", "method": "ping"}, {"jsonrpc": "2.0", "method": "ping"}]') +'' +``` + +### Invalid members + +Each request in a batch is checked on its own. One that isn't a valid request +gets its own -32600 "Invalid request" response, with `id` null, and the +others run as usual: + +```pycon +>>> dispatch('[1, {"jsonrpc": "2.0", "method": "ping", "id": 1}]') +'[{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The request failed schema validation"}, "id": null}, {"jsonrpc": "2.0", "result": "pong", "id": 1}]' +``` + +An array inside a batch is not a request either, so it gets the same error. + +!!! info "Changed in 5.0.10" + In 5.0.9, one invalid member got the whole batch rejected with a single + "Invalid request" response. + +### An empty batch + +The spec says an empty array gets a single error response, not an empty +array: + +```pycon +>>> dispatch("[]") +'{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The request failed schema validation"}, "id": null}' +``` + +## Limit the batch size + +A client can put thousands of requests in one batch, and each one is +validated and run. Set `max_batch_size` on every dispatch call on a server +that strangers can reach: + +```pycon +>>> big_batch = ( +... "[" + ", ".join(['{"jsonrpc": "2.0", "method": "ping", "id": 1}'] * 101) + "]" +... ) +>>> dispatch(big_batch, max_batch_size=100) +'{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The batch has 101 requests. The limit is 100."}, "id": null}' +``` + +A batch over the limit gets one error response, and none of its requests run. +A single request that isn't in a batch is never affected. The default, +`None`, means no limit. + +`max_batch_size` must be `None` or a positive `int`. Anything else, including +`0`, `True`, `1.5` or `"100"`, raises `ValueError` when you call `dispatch`: + +```pycon +>>> dispatch(big_batch, max_batch_size=0) +Traceback (most recent call last): + ... +ValueError: max_batch_size must be a positive int or None, not 0 +``` + +!!! info "New in 5.0.10" + `max_batch_size`. On 5.0.9, limit the size of the request body in your + web server instead. [Security](security.md#limit-batch-size) explains + why both matter. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..f305617 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,5 @@ +--- +description: Every jsonrpcserver release and what changed in it, from the first release to the latest. +--- + +--8<-- "CHANGELOG.md" diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..90041f9 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,163 @@ +--- +description: Give jsonrpcserver methods the HTTP request, the logged-in user or a database session through context, with examples for Flask, FastAPI and Django. +--- + +# Context + +Methods often need something from the server that the client mustn't control: +the logged-in user, the HTTP request, a database session. Pass it as +`context`. It becomes the first argument of every method, and the client can't +see or change it. + +A small class keeps the context tidy when there's more than one thing to pass: + +```python +from dataclasses import dataclass +from typing import Any + +from jsonrpcserver import Result, Success, dispatch + + +@dataclass +class Context: + user: str + request: Any + + +def whoami(context: Context) -> Result: + return Success(context.user) + + +def user_for(authorization: str) -> str: + # Look the token up in your user store. This is a stand-in. + return {"Bearer abc123": "beau"}.get(authorization, "anonymous") + + +METHODS = {"whoami": whoami} +``` + +The method's other parameters come after `context`, and the request's +`params` fill those as usual. + +## Flask + + +```python +from flask import Flask, Response, request + +app = Flask(__name__) + + +@app.post("/") +def index() -> Response: + context = Context(user_for(request.headers.get("Authorization", "")), request) + if response := dispatch( + request.get_data(as_text=True), METHODS, context=context, max_batch_size=100 + ): + return Response(response, content_type="application/json") + return Response(status=204) +``` + +Trying it with Flask's test client: + + +```pycon +>>> client = app.test_client() +>>> client.post( +... "/", +... data='{"jsonrpc": "2.0", "method": "whoami", "id": 1}', +... headers={"Authorization": "Bearer abc123"}, +... ).get_data(as_text=True) +'{"jsonrpc": "2.0", "result": "beau", "id": 1}' +``` + +## FastAPI + + +```python +from fastapi import FastAPI, Request +from fastapi import Response as FastAPIResponse + +from jsonrpcserver import async_dispatch + +api = FastAPI() + + +@api.post("/") +async def endpoint(request: Request) -> FastAPIResponse: + context = Context(user_for(request.headers.get("Authorization", "")), request) + body = (await request.body()).decode() + if response := await async_dispatch( + body, METHODS, context=context, max_batch_size=100 + ): + return FastAPIResponse(response, media_type="application/json") + return FastAPIResponse(status_code=204) +``` + +`async_dispatch` calls the plain `whoami` method too. To try it, FastAPI's +test client needs [httpx2](https://pypi.org/project/httpx2/): + + +```pycon +>>> from fastapi.testclient import TestClient +>>> TestClient(api).post( +... "/", +... content='{"jsonrpc": "2.0", "method": "whoami", "id": 1}', +... headers={"Authorization": "Bearer abc123"}, +... ).text +'{"jsonrpc": "2.0", "result": "beau", "id": 1}' +``` + +FastAPI's `Depends` doesn't reach into methods, because jsonrpcserver calls +them, not FastAPI. Resolve what you need in the endpoint, as above, and pass +it in the context. + +## Django + + +```python +from django.http import HttpRequest, HttpResponse +from django.views.decorators.csrf import csrf_exempt + + +@csrf_exempt +def jsonrpc(request: HttpRequest) -> HttpResponse: + # With Django's authentication, use request.user instead of user_for. + context = Context(user_for(request.headers.get("Authorization", "")), request) + if response := dispatch( + request.body.decode(), METHODS, context=context, max_batch_size=100 + ): + return HttpResponse(response, content_type="application/json") + return HttpResponse(status=204) +``` + +## A database session per request + +Open the session in the view, pass it in the context, and close it when +`dispatch` returns. Every method in a batch then shares the one session: + +```python +from contextlib import closing +import sqlite3 + + +def count_users(context: sqlite3.Connection) -> Result: + (count,) = context.execute("SELECT count(*) FROM users").fetchone() + return Success(count) + + +def handle(request_body: str) -> str: + with closing(sqlite3.connect(":memory:")) as db: + db.execute("CREATE TABLE users (name TEXT)") # Stand-in for a real database. + return dispatch( + request_body, {"count_users": count_users}, context=db, max_batch_size=100 + ) +``` + +```pycon +>>> handle('{"jsonrpc": "2.0", "method": "count_users", "id": 1}') +'{"jsonrpc": "2.0", "result": 0, "id": 1}' +``` + +With `async_dispatch`, the requests in a batch run concurrently. Make sure the +shared object can be used that way, or open what you need in each method. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..1506091 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,5 @@ +--- +description: How to set up jsonrpcserver for development, run its checks, and send a pull request. +--- + +--8<-- "CONTRIBUTING.md" diff --git a/docs/dispatch.md b/docs/dispatch.md index bbefd8f..72f9649 100644 --- a/docs/dispatch.md +++ b/docs/dispatch.md @@ -1,3 +1,7 @@ +--- +description: The dispatch function in jsonrpcserver and every option it takes, from methods and context to debug, max_batch_size, deserializer, serializer and validator. Plus the dict and Response forms of the result. +--- + # Dispatch `dispatch` takes a JSON-RPC request string, calls the method and gives a @@ -12,7 +16,7 @@ def ping() -> Result: return Success("pong") ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') '{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` @@ -20,22 +24,18 @@ def ping() -> Result: It never raises for a bad request or a failing method. Those become JSON-RPC error responses, as the spec requires: -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "nope", "id": 1}') '{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found", "data": "nope"}, "id": 1}' >>> dispatch("{") '{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error", "data": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}, "id": null}' ``` -A batch gets a list of responses, in the same order: - -```python ->>> dispatch('[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]') -'[{"jsonrpc": "2.0", "result": "pong", "id": 1}, {"jsonrpc": "2.0", "result": "pong", "id": 2}]' -``` +[Errors and logging](errors.md) lists every error it can send. A notification, or a batch of only notifications, gives an empty string. The -spec says not to respond to those. +spec says not to respond to those. [Notifications and batches](batches.md) +covers both. [See how dispatch is used in different frameworks.](examples.md) @@ -54,12 +54,16 @@ def multiply(a: int, b: int) -> Result: return Success(a * b) ``` -```python ->>> dispatch('{"jsonrpc": "2.0", "method": "multiply", "params": [2, 3], "id": 1}', methods={"multiply": multiply}) +```pycon +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "multiply", "params": [2, 3], "id": 1}', +... methods={"multiply": multiply}, +... ) '{"jsonrpc": "2.0", "result": 6, "id": 1}' ``` -The default is the dict that `@method` fills in. +The default is the dict that `@method` fills in, +`jsonrpcserver.methods.global_methods`. Any mapping works, not only a dict. ### context @@ -71,12 +75,17 @@ def greet(context: str, name: str) -> Result: return Success(context + " " + name) ``` -```python ->>> dispatch('{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', methods={"greet": greet}, context="Hello") +```pycon +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', +... methods={"greet": greet}, +... context="Hello", +... ) '{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' ``` -The client can't see or set it. +The client can't see or set it. [Context](context.md) shows how to pass the +HTTP request or the user from Flask, FastAPI and Django. ### debug @@ -89,12 +98,16 @@ def broken() -> Result: raise ValueError("Something went wrong") ``` -```python +```pycon >>> import logging >>> logging.disable(logging.CRITICAL) # Keep the logged traceback out of this page. >>> dispatch('{"jsonrpc": "2.0", "method": "broken", "id": 1}', methods={"broken": broken}) '{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error"}, "id": 1}' ->>> dispatch('{"jsonrpc": "2.0", "method": "broken", "id": 1}', methods={"broken": broken}, debug=True) +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "broken", "id": 1}', +... methods={"broken": broken}, +... debug=True, +... ) '{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error", "data": "Something went wrong"}, "id": 1}' >>> logging.disable(logging.NOTSET) ``` @@ -102,18 +115,31 @@ def broken() -> Result: Only use it in development. Exception messages can include passwords, file paths and SQL. The default is `False`. +!!! info "New in 5.0.10" + The `debug` option, and leaving the message out by default. In 5.0.0 to + 5.0.9 the message is always sent, and `debug` raises `TypeError`. (4.x + had `debug` too, off by default.) See + [Security](security.md#if-you-are-on-509). + ### max_batch_size The most requests a batch may hold. A bigger batch gets a single -32600 "Invalid request" response, and none of its requests are run: -```python ->>> dispatch('[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', max_batch_size=1) +```pycon +>>> dispatch( +... '[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', +... max_batch_size=1, +... ) '{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The batch has 2 requests. The limit is 1."}, "id": null}' ``` The default, `None`, means no limit. A server open to the internet should set -one. See [Security](security.md). +one. See [Security](security.md#limit-batch-size). Anything other than `None` +or a positive `int` raises `ValueError`. + +!!! info "New in 5.0.10" + 5.0.9 raises `TypeError` for this keyword. ### deserializer @@ -126,6 +152,9 @@ import ujson dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', deserializer=ujson.loads) ``` +If it raises, the client gets a -32700 "Parse error" whose `data` is the +exception's message, so don't put anything secret in it. + ### serializer The function that turns the response into a string. The default is @@ -133,42 +162,82 @@ The function that turns the response into a string. The default is `Infinity` gives an Internal error instead of output that isn't valid JSON. If the serializer raises for a response (say the method returned a -`datetime`), that response becomes an Internal error. The rest of a batch is -sent as usual. +`datetime`), that response becomes an Internal error, and the error is logged. +The rest of a batch is sent as usual. ```python dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', serializer=ujson.dumps) ``` +!!! info "Changed in 5.0.10" + 5.0.9 writes `NaN` and `Infinity` into the response, and raises when the + serializer fails. + ### validator The function that checks each request against the JSON-RPC spec, after -parsing. It should raise an exception, any exception, if the request is -invalid. In a batch, it's called once for each request. The default checks -against a JSON schema. - -Validation takes most of the time for a small method. If you're sure the -requests are valid, turn it off: +parsing. The default checks against a JSON schema. [Validation](validation.md) +says what it checks, how to write your own, and what you lose by turning it +off. -```python -dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', validator=lambda _: None) -``` +!!! info "Changed in 5.0.10" + In a batch, the validator is called once for each request. In 5.0.9 it + was called once with the whole list. ## Other return types -`dispatch` is an alias for `dispatch_to_json`. Two other functions take the -same options, apart from `serializer`, and give the response in other forms: +`dispatch` is also called `dispatch_to_json`. Two other functions take the +same options, apart from `serializer`, and give the response in other forms. -- `dispatch_to_serializable` gives a dict, a list of dicts for a batch, or - `None` for a notification. -- `dispatch_to_response` gives `Response` objects. Each is an oslash `Right` - holding a `SuccessResponse`, or a `Left` holding an `ErrorResponse`. Both are - defined in - [`jsonrpcserver.response`](https://github.com/bensynapse/jsonrpcserver/blob/main/jsonrpcserver/response.py). +### dispatch_to_serializable -```python +It gives a dict, a list of dicts for a batch, or `None` for a notification. +Use it when your framework serializes the response itself: + +```pycon >>> from jsonrpcserver import dispatch_to_serializable >>> dispatch_to_serializable('{"jsonrpc": "2.0", "method": "ping", "id": 1}') {'jsonrpc': '2.0', 'result': 'pong', 'id': 1} ``` + +### dispatch_to_response + +It gives `Response` objects, or `None` for a notification. Use it to look at +or change responses before they're serialized. It also takes a `post_process` +function, which is applied to each response. + +A `Response` comes from the [oslash](https://pypi.org/project/oslash/) +library. It's a `Right` holding a `SuccessResponse`, or a `Left` holding an +`ErrorResponse`. oslash has no public way to read them, so check which one you +have and read `_value` or `_error`. Those attributes are stable for all of +5.x: + +```pycon +>>> from oslash.either import Left +>>> from jsonrpcserver import dispatch_to_response +>>> response = dispatch_to_response('{"jsonrpc": "2.0", "method": "ping", "id": 1}') +>>> isinstance(response, Left) +False +>>> response._value +SuccessResponse(result='pong', id=1) +>>> error = dispatch_to_response('{"jsonrpc": "2.0", "method": "nope", "id": 1}') +>>> isinstance(error, Left) +True +>>> error._error +ErrorResponse(code=-32601, message='Method not found', data='nope', id=1) +``` + +`print(response)` raises `TypeError: not all arguments converted during +string formatting`, because of a bug in oslash. Turn it into a dict first with +`to_dict`: + +```pycon +>>> from jsonrpcserver.response import to_dict +>>> print(to_dict(response)) +{'jsonrpc': '2.0', 'result': 'pong', 'id': 1} +``` + +For a batch, it gives a list of Responses. Everything here works the same +with `async_dispatch_to_serializable` and `async_dispatch_to_response`. See +[Async](async.md). diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..cd022d8 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,183 @@ +--- +description: Every error code jsonrpcserver sends, what its data holds, what reaches the client and what is logged. The jsonrpcserver loggers, debug mode, and how to see tracebacks. +--- + +# Errors and logging + +## The errors the client can get + +These are the errors jsonrpcserver sends by itself. Your own errors, from +`Error` or `JsonRpcError`, are sent exactly as you made them. + +| Code | Message | When | `data` | +|---|---|---|---| +| -32700 | Parse error | The request isn't valid JSON, or your `deserializer` raised. | The deserializer's exception message. | +| -32600 | Invalid request | The JSON isn't a valid request, a batch is empty, or a batch is bigger than `max_batch_size`. | "The request failed schema validation", or the batch size and the limit. | +| -32601 | Method not found | No method has that name. | The method name from the request. | +| -32602 | Invalid params | The params don't fit the method's signature. | Python's explanation, which names the parameter, such as "missing a required argument: 'name'". | +| -32603 | Internal error | The method raised an exception, returned something other than `Success` or `Error`, or returned a result the serializer couldn't handle. | None. With `debug=True`, the exception message. | +| -32000 | Server error | Something failed inside jsonrpcserver, outside any method. For example, a custom validator let through a request with no `method`. | None. With `debug=True`, the exception message. | + +`debug` only changes the last two rows. The `data` in the other rows always +reaches the client. Keep that in mind if you write a custom deserializer, +because its exception messages are sent as they are. + +!!! info "Changed in 5.0.10" + From 5.0.0 to 5.0.9, the -32603 and -32000 errors always carried the + exception message. It could hold passwords, paths or SQL. See + [Security](security.md). 4.x had a `debug` option that hid it by default, + and 5.0.10 brings that back. + +## Four ways for a method to fail + +```python +import logging + +from jsonrpcserver import Error, InvalidParams, JsonRpcError, Result, Success, dispatch + + +def by_return(amount: int) -> Result: + return Error(1, "Insufficient funds", {"balance": 10}) + + +def by_raise(amount: int) -> Result: + raise JsonRpcError(1, "Insufficient funds", {"balance": 10}) + + +def bad_value(amount: int) -> Result: + return InvalidParams("amount must be positive") + + +def by_accident(amount: int) -> Result: + return Success(amount / 0) + + +methods = { + "by_return": by_return, + "by_raise": by_raise, + "bad_value": bad_value, + "by_accident": by_accident, +} +``` + +```pycon +>>> logging.disable(logging.CRITICAL) # Keep the traceback out of this page. +>>> dispatch('{"jsonrpc": "2.0", "method": "by_return", "params": [50], "id": 1}', methods) +'{"jsonrpc": "2.0", "error": {"code": 1, "message": "Insufficient funds", "data": {"balance": 10}}, "id": 1}' +>>> dispatch('{"jsonrpc": "2.0", "method": "by_raise", "params": [50], "id": 1}', methods) +'{"jsonrpc": "2.0", "error": {"code": 1, "message": "Insufficient funds", "data": {"balance": 10}}, "id": 1}' +>>> dispatch('{"jsonrpc": "2.0", "method": "bad_value", "params": [-5], "id": 1}', methods) +'{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": "amount must be positive"}, "id": 1}' +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "by_accident", "params": [50], "id": 1}', methods +... ) +'{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error"}, "id": 1}' +>>> logging.disable(logging.NOTSET) +``` + +- **Return `Error`** for errors the client should know about. It's the + normal way. +- **Raise `JsonRpcError`** when the error is found deep inside other + functions. The response is the same as returning `Error`. +- **Return `InvalidParams`** when the arguments have the right shape but a + bad value. +- **An uncaught exception** is a bug as far as the client is concerned. It + gets a bare Internal error, and the details go to the log. + +Errors you make on purpose are sent as they are, whatever `debug` is, so +don't put secrets in them. + +## Debug mode + +`debug=True` adds the exception message to the -32603 and -32000 errors, so +you can see what went wrong without reading the log: + +```pycon +>>> logging.disable(logging.CRITICAL) +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "by_accident", "params": [50], "id": 1}', +... methods, +... debug=True, +... ) +'{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error", "data": "division by zero"}, "id": 1}' +>>> logging.disable(logging.NOTSET) +``` + +Use it in development only. It's the same option on every dispatch function, +sync and async. It's new in 5.0.10. + +## Spec warnings + +The spec says an error code must be an integer, and the message should be a +string. It also reserves method names that start with `rpc.`. jsonrpcserver +warns about these with a `UserWarning`, but still sends what you gave it, so +existing code keeps working: + +```pycon +>>> import warnings +>>> with warnings.catch_warnings(record=True) as caught: +... warnings.simplefilter("always") +... _ = Error("E1", "Insufficient funds") +>>> print(caught[0].message) +JSON-RPC error codes must be integers, not 'E1' +``` + +To make them errors in your tests, run pytest with `-W error::UserWarning`. +These warnings are new in 5.0.10. 6.0 may turn them into errors. + +## Logging + +Everything is logged on the `jsonrpcserver` logger or one of its children: + +| Logger | What it logs | +|---|---| +| `jsonrpcserver.dispatcher` | For `dispatch`: an exception a method didn't catch (ERROR, with the traceback). A method that returned something other than `Success` or `Error` (ERROR). A failure inside jsonrpcserver (ERROR, with the traceback). | +| `jsonrpcserver.async_dispatcher` | The same, for `async_dispatch`. | +| `jsonrpcserver.main` | A result the serializer couldn't handle, such as a `datetime` (ERROR, with the traceback). Both sync and async. | +| `jsonrpcserver.server` | `serve()` only: where it's listening, and one line per HTTP request (INFO). | + +Invalid requests, unknown methods and params that don't fit aren't logged. +The client gets the error, and it's not a problem with your server. + +jsonrpcserver doesn't configure logging itself. If nothing configures it, +Python still prints warnings and errors to stderr, but without the time or the +logger's name. To see them properly, configure logging when your server +starts: + +```python +logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s" +) +``` + +To change only jsonrpcserver's messages, set the level or add a handler on +the parent logger: `logging.getLogger("jsonrpcserver")`. + +This is what a method that returns a plain value, as it would in 4.x, looks +like in the log: + +```python +import sys + +handler = logging.StreamHandler(sys.stdout) +handler.setFormatter(logging.Formatter("%(name)s %(levelname)s: %(message)s")) +logging.getLogger("jsonrpcserver").addHandler(handler) + + +def ping() -> str: + return "pong" # Should be Success("pong") + + +dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping}) +logging.getLogger("jsonrpcserver").removeHandler(handler) +``` + +```text title="Output" +jsonrpcserver.dispatcher ERROR: Method 'ping' returned 'pong', which is not a Result, so the client got an Internal error. Return Success(value) or Error(code, message). Since 5.0 a plain return value is not enough: https://bensynapse.github.io/jsonrpcserver/migration/ +``` + +!!! info "New in 5.0.10" + The `jsonrpcserver.main` messages and this one. Before 5.0.10, a + serializer failure raised out of `dispatch`. A plain return value was + logged with a traceback, and its explanation was also sent to the client + in `data`. diff --git a/docs/examples.md b/docs/examples.md index 78bcf48..829ef4b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,102 +1,56 @@ -# Examples - -jsonrpcserver doesn't listen on a port itself. You receive the request with a -framework or transport library, pass it to `dispatch` or `async_dispatch`, and -send back the result. Each example below answers `ping` with `pong` on -`localhost:5000`. CI starts every one of them and sends it real requests. - -For HTTP, send status 204 with an empty body when `dispatch` gives an empty -string. That means the request was a notification. With websockets and most -message queues, you can send nothing at all. - -The libraries' own docs cover running them in production. - -## http.server - -Python's built-in HTTP server, with no other dependencies: - -```python ---8<-- "docs/examples/http_server.py" -``` - -jsonrpcserver also has a small built-in server, `serve()`. It's for trying -things out, not for production: - -```python ---8<-- "docs/examples/serve.py" -``` - -## Flask - -```python ---8<-- "docs/examples/flask_server.py" -``` - -## Werkzeug - -```python ---8<-- "docs/examples/werkzeug_server.py" -``` - -## Django - -A whole Django project in one file. In your project, the view goes in an app -and the URL pattern in its `urls.py`. - -```python ---8<-- "docs/examples/django_server.py" -``` - -## FastAPI - -```python ---8<-- "docs/examples/fastapi_server.py" -``` - -## aiohttp - -```python ---8<-- "docs/examples/aiohttp_server.py" -``` - -## Sanic - -```python ---8<-- "docs/examples/sanic_server.py" -``` - -## Tornado - -```python ---8<-- "docs/examples/tornado_server.py" -``` - -## websockets - -Uses the `websockets.asyncio` server from websockets 13 and later. - -```python ---8<-- "docs/examples/websockets_server.py" -``` - -## ZeroMQ - -Using [pyzmq](https://pyzmq.readthedocs.io/): - -```python ---8<-- "docs/examples/zeromq_server.py" -``` - -With asyncio, using pyzmq's `zmq.asyncio`: - -```python ---8<-- "docs/examples/zeromq_async_server.py" -``` - -## Socket.IO - -Using [Flask-SocketIO](https://flask-socketio.readthedocs.io/): - -```python ---8<-- "docs/examples/socketio_server.py" -``` +--- +description: Use jsonrpcserver with any Python framework or transport. Complete, tested examples for http.server, Flask, Werkzeug, Django, FastAPI, aiohttp, Sanic, Tornado, websockets, ZeroMQ and Socket.IO. +--- + +# Frameworks + +jsonrpcserver doesn't do the networking. You receive the request with a +framework or transport library, pass it to `dispatch` or `async_dispatch`, +and send back the result. Each page below has a complete example that answers +`ping` with `pong` on `localhost:8000`. CI starts every one of them and sends +it real requests, including a batch that's too big and, for HTTP, a body +that's too big. + +| Framework | Transport | Sync or async | Body size limit in the example | Page | +|---|---|---|---|---| +| `http.server` (standard library) | HTTP | sync | checked by hand | [http.server and serve()](frameworks/http-server.md) | +| [Flask](https://flask.palletsprojects.com/) | HTTP | sync | `MAX_CONTENT_LENGTH` | [Flask](frameworks/flask.md) | +| [Werkzeug](https://werkzeug.palletsprojects.com/) | HTTP | sync | `max_content_length` | [Werkzeug](frameworks/werkzeug.md) | +| [Django](https://www.djangoproject.com/) | HTTP | sync | `DATA_UPLOAD_MAX_MEMORY_SIZE` | [Django](frameworks/django.md) | +| [FastAPI](https://fastapi.tiangolo.com/) | HTTP | async | checked by hand | [FastAPI](frameworks/fastapi.md) | +| [aiohttp](https://docs.aiohttp.org/) | HTTP | async | `client_max_size` | [aiohttp](frameworks/aiohttp.md) | +| [Sanic](https://sanic.dev/) | HTTP | async | `REQUEST_MAX_SIZE` | [Sanic](frameworks/sanic.md) | +| [Tornado](https://www.tornadoweb.org/) | HTTP | async | `max_body_size` | [Tornado](frameworks/tornado.md) | +| [websockets](https://websockets.readthedocs.io/) | WebSocket | async | `max_size` | [websockets](frameworks/websockets.md) | +| [pyzmq](https://pyzmq.readthedocs.io/) | ZeroMQ | both | `MAXMSGSIZE` | [ZeroMQ](frameworks/zeromq.md) | +| [Flask-SocketIO](https://flask-socketio.readthedocs.io/) | Socket.IO | sync | `max_http_buffer_size` | [Socket.IO](frameworks/socketio.md) | + +The pattern is the same everywhere: + +1. Read the request body as a string. Refuse one that's too big before you + read it. +2. Pass it to `dispatch`, or `await async_dispatch(...)` in an async + framework, with `max_batch_size` set. +3. If the result is a non-empty string, send it with + `Content-Type: application/json` and status 200. If it's empty, the + request was a notification: over HTTP, send status 204 with no body. Over + other transports, send nothing (ZeroMQ's REQ/REP sockets are the exception: + they must reply, so send the empty string). + +A JSON-RPC error is still a successful HTTP exchange, so it's sent with +status 200 too. + +Every example limits the body to 1,000,000 bytes and batches to 100 requests. +Pick limits that fit your methods. [Security](security.md) explains why both +matter. To give methods the request or the logged-in user, see +[Context](context.md). + +!!! tip "Port 8000" + The examples use port 8000, the same as the + [jsonrpcclient](https://bensynapse.github.io/jsonrpcclient/) examples. + Run one of each and they talk to each other. Port 5000, which many + tutorials use, is taken by the AirPlay Receiver on recent macOS versions. + +The examples listen on `localhost` only. To accept connections from other +machines, change it to the address you want. Then put the server behind a +production web server, as each framework's docs describe. diff --git a/docs/examples/aiohttp_server.py b/docs/examples/aiohttp_server.py index 11e0137..31e40de 100644 --- a/docs/examples/aiohttp_server.py +++ b/docs/examples/aiohttp_server.py @@ -9,13 +9,15 @@ async def ping() -> Result: async def handle(request: web.Request) -> web.Response: - if response := await async_dispatch(await request.text()): + # max_batch_size: see the Security page. + if response := await async_dispatch(await request.text(), max_batch_size=100): return web.Response(text=response, content_type="application/json") return web.Response(status=204) -app = web.Application() +# Bigger requests get 413 Request Entity Too Large. The default is 1 MiB. +app = web.Application(client_max_size=1_000_000) app.router.add_post("/", handle) if __name__ == "__main__": - web.run_app(app, host="localhost", port=5000) + web.run_app(app, host="localhost", port=8000) diff --git a/docs/examples/check_examples.py b/docs/examples/check_examples.py index b755796..7f59104 100644 --- a/docs/examples/check_examples.py +++ b/docs/examples/check_examples.py @@ -4,24 +4,51 @@ Each server must answer a "ping" request with "pong". The HTTP servers must also answer a notification with 204 No Content and an empty body. The servers -listen on localhost port 5000, as the docs say. Needs the packages in -requirements/examples.txt. +that follow the Security page must refuse a batch over 100 requests and a body +over 1,000,000 bytes. The servers listen on localhost port 8000, as the docs +say. Needs the packages in requirements/examples.txt, and curl. + +It also sends the quickstart server the curl command from the docs. """ +import http.client import json +import shlex import socket import subprocess import sys import time +import urllib.error import urllib.request from pathlib import Path -from typing import Callable, Dict, List, Tuple +from typing import Callable, Dict, List, Optional, Tuple HERE = Path(__file__).parent -PORT = 5000 +PORT = 8000 REQUEST = json.dumps({"jsonrpc": "2.0", "method": "ping", "id": 1}) NOTIFICATION = json.dumps({"jsonrpc": "2.0", "method": "ping"}) EXPECTED = {"jsonrpc": "2.0", "result": "pong", "id": 1} +BIG_BATCH = json.dumps( + [{"jsonrpc": "2.0", "method": "ping", "id": n} for n in range(101)] +) +BATCH_REFUSED = { + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid request", + "data": "The batch has 101 requests. The limit is 100.", + }, + "id": None, +} +MAX_BODY = 1_000_000 + +# The curl command in the README and on the home page. tests/test_docs.py +# checks that they show this command and this output. +CURL = ( + "curl -s -H 'Content-Type: application/json' " + """-d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/""" +) +CURL_OUTPUT = '{"jsonrpc": "2.0", "result": "pong", "id": 1}' def post(body: str) -> Tuple[int, str, str]: @@ -30,15 +57,46 @@ def post(body: str) -> Tuple[int, str, str]: data=body.encode(), headers={"Content-Type": "application/json"}, ) - with urllib.request.urlopen(request, timeout=10) as response: - return ( - response.status, - response.headers.get("Content-Type", ""), - response.read().decode(), - ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return ( + response.status, + response.headers.get("Content-Type", ""), + response.read().decode(), + ) + except urllib.error.HTTPError as exc: + return exc.code, exc.headers.get("Content-Type", ""), exc.read().decode() + + +def post_raw(body: Optional[bytes]) -> int: + """POST without urllib's help: no Content-Length when body is None.""" + connection = http.client.HTTPConnection("localhost", PORT, timeout=10) + try: + if body is None: + connection.putrequest("POST", "/") + connection.putheader("Content-Type", "application/json") + connection.endheaders() + else: + connection.request( + "POST", "/", body=body, headers={"Content-Type": "application/json"} + ) + return connection.getresponse().status + finally: + connection.close() + + +def post_raw_body(body: bytes) -> Tuple[int, str]: + """POST bytes that may not be valid UTF-8. Return the status and body.""" + connection = http.client.HTTPConnection("localhost", PORT, timeout=10) + try: + connection.request("POST", "/", body=body) + response = connection.getresponse() + return response.status, response.read().decode() + finally: + connection.close() -def check_http() -> None: +def check_ping_and_notification() -> None: status, content_type, body = post(REQUEST) assert status == 200, status assert content_type.startswith("application/json"), content_type @@ -47,12 +105,47 @@ def check_http() -> None: assert (status, body) == (204, ""), (status, body) +def check_limits(too_large: int) -> None: + status, _, body = post(BIG_BATCH) + assert status == 200, status + assert json.loads(body) == BATCH_REFUSED, body + status = post_raw(b" " * (MAX_BODY + 1)) + assert status == too_large, status + + +def check_quickstart() -> None: + check_ping_and_notification() + output = subprocess.run( + shlex.split(CURL), capture_output=True, text=True, timeout=30, check=True + ).stdout + assert output == CURL_OUTPUT, output + + +def check_http_server() -> None: + check_ping_and_notification() + check_limits(413) + assert post_raw(None) == 411 + status, body = post_raw_body(b'{"jsonrpc": "2.0", "method": "\xff", "id": 1}') + assert status == 200, status + assert json.loads(body)["error"]["code"] == -32700, body + + +def check_http(too_large: int) -> Callable[[], None]: + def check() -> None: + check_ping_and_notification() + check_limits(too_large) + + return check + + def check_websockets() -> None: from websockets.sync.client import connect - with connect(f"ws://localhost:{PORT}") as websocket: + with connect(f"ws://localhost:{PORT}", max_size=None) as websocket: websocket.send(REQUEST) assert json.loads(websocket.recv(timeout=10)) == EXPECTED + websocket.send(BIG_BATCH) + assert json.loads(websocket.recv(timeout=10)) == BATCH_REFUSED def check_zeromq() -> None: @@ -68,6 +161,8 @@ def check_zeromq() -> None: assert json.loads(client.recv_string()) == EXPECTED client.send_string(NOTIFICATION) assert client.recv_string() == "" + client.send_string(BIG_BATCH) + assert json.loads(client.recv_string()) == BATCH_REFUSED finally: client.close() context.term() @@ -82,23 +177,29 @@ def check_socketio() -> None: event, data = client.receive(timeout=10) assert event == "message", event assert json.loads(data) == EXPECTED, data + client.emit("message", BIG_BATCH) + event, data = client.receive(timeout=10) + assert json.loads(data) == BATCH_REFUSED, data EXAMPLES: Dict[str, Callable[[], None]] = { - "http_server.py": check_http, - "serve.py": check_http, - "flask_server.py": check_http, - "werkzeug_server.py": check_http, - "django_server.py": check_http, - "fastapi_server.py": check_http, - "aiohttp_server.py": check_http, - "sanic_server.py": check_http, - "tornado_server.py": check_http, + "quickstart.py": check_quickstart, + "http_server.py": check_http_server, + "flask_server.py": check_http(413), + "werkzeug_server.py": check_http(413), + # Django answers a body over DATA_UPLOAD_MAX_MEMORY_SIZE with 400. + "django_server.py": check_http(400), + "fastapi_server.py": check_http(413), + "aiohttp_server.py": check_http(413), + "sanic_server.py": check_http(413), + "tornado_server.py": check_http(400), "websockets_server.py": check_websockets, "zeromq_server.py": check_zeromq, "zeromq_async_server.py": check_zeromq, "socketio_server.py": check_socketio, } +# Files that aren't servers. +NOT_SERVERS = {"check_examples.py"} def port_open() -> bool: @@ -148,7 +249,7 @@ def run(example: str) -> bool: def main(names: List[str]) -> int: - on_disk = {path.name for path in HERE.glob("*.py")} - {Path(__file__).name} + on_disk = {path.name for path in HERE.glob("*.py")} - NOT_SERVERS missing = on_disk - set(EXAMPLES) if missing: print(f"Examples with no check: {sorted(missing)}") diff --git a/docs/examples/django_server.py b/docs/examples/django_server.py index db58863..3991ff3 100644 --- a/docs/examples/django_server.py +++ b/docs/examples/django_server.py @@ -13,6 +13,8 @@ ROOT_URLCONF=__name__, ALLOWED_HOSTS=["localhost", "127.0.0.1"], SECRET_KEY="replace-me", + # Bigger requests get 400 Bad Request. Django's default is 2.5 MB. + DATA_UPLOAD_MAX_MEMORY_SIZE=1_000_000, ) @@ -23,7 +25,8 @@ def ping() -> Result: @csrf_exempt def jsonrpc(request: HttpRequest) -> HttpResponse: - if response := dispatch(request.body.decode()): + # max_batch_size: see the Security page. + if response := dispatch(request.body.decode(), max_batch_size=100): return HttpResponse(response, content_type="application/json") return HttpResponse(status=204) @@ -33,4 +36,6 @@ def jsonrpc(request: HttpRequest) -> HttpResponse: if __name__ == "__main__": from django.core.management import execute_from_command_line - execute_from_command_line([sys.argv[0], "runserver", "5000", "--noreload"]) + execute_from_command_line( + [sys.argv[0], "runserver", "localhost:8000", "--noreload"] + ) diff --git a/docs/examples/fastapi_server.py b/docs/examples/fastapi_server.py index 4a36856..96fffbd 100644 --- a/docs/examples/fastapi_server.py +++ b/docs/examples/fastapi_server.py @@ -3,6 +3,8 @@ from jsonrpcserver import Result, Success, async_dispatch, method +MAX_BODY = 1_000_000 # bytes + app = FastAPI() @@ -13,10 +15,18 @@ async def ping() -> Result: @app.post("/") async def index(request: Request) -> Response: - if response := await async_dispatch((await request.body()).decode()): + # FastAPI has no limit on the body size, so read it in chunks and stop + # when it gets too big. + body = b"" + async for chunk in request.stream(): + body += chunk + if len(body) > MAX_BODY: + return Response(status_code=413) + # max_batch_size: see the Security page. + if response := await async_dispatch(body.decode(), max_batch_size=100): return Response(response, media_type="application/json") return Response(status_code=204) if __name__ == "__main__": - uvicorn.run(app, host="localhost", port=5000) + uvicorn.run(app, host="localhost", port=8000) diff --git a/docs/examples/flask_server.py b/docs/examples/flask_server.py index 6212f7a..210bbc8 100644 --- a/docs/examples/flask_server.py +++ b/docs/examples/flask_server.py @@ -3,6 +3,8 @@ from jsonrpcserver import Result, Success, dispatch, method app = Flask(__name__) +# Bigger requests get 413 Request Entity Too Large. +app.config["MAX_CONTENT_LENGTH"] = 1_000_000 @method @@ -12,10 +14,11 @@ def ping() -> Result: @app.post("/") def index() -> Response: - if response := dispatch(request.get_data(as_text=True)): + # max_batch_size: see the Security page. + if response := dispatch(request.get_data(as_text=True), max_batch_size=100): return Response(response, content_type="application/json") return Response(status=204) if __name__ == "__main__": - app.run(port=5000) + app.run(host="localhost", port=8000) diff --git a/docs/examples/http_server.py b/docs/examples/http_server.py index 92dc91e..b4b29e9 100644 --- a/docs/examples/http_server.py +++ b/docs/examples/http_server.py @@ -1,7 +1,10 @@ +import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from jsonrpcserver import Result, Success, dispatch, method +MAX_BODY = 1_000_000 # bytes + @method def ping() -> Result: @@ -10,8 +13,26 @@ def ping() -> Result: class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: - length = int(self.headers["Content-Length"]) - response = dispatch(self.rfile.read(length).decode()) + length = self.headers.get("Content-Length", "") + if not length.isdecimal(): + self.send_error(411, "Content-Length required") + return + if int(length) > MAX_BODY: + self.send_error(413, "Request body too large") + return + try: + request = self.rfile.read(int(length)).decode("utf-8") + except UnicodeDecodeError: + response = json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Parse error"}, + "id": None, + } + ) + else: + # max_batch_size: see the Security page. + response = dispatch(request, max_batch_size=100) if response: body = response.encode() self.send_response(200) @@ -26,4 +47,4 @@ def do_POST(self) -> None: if __name__ == "__main__": - ThreadingHTTPServer(("localhost", 5000), Handler).serve_forever() + ThreadingHTTPServer(("localhost", 8000), Handler).serve_forever() diff --git a/docs/examples/serve.py b/docs/examples/quickstart.py similarity index 83% rename from docs/examples/serve.py rename to docs/examples/quickstart.py index db6a68d..520b935 100644 --- a/docs/examples/serve.py +++ b/docs/examples/quickstart.py @@ -7,4 +7,4 @@ def ping() -> Result: if __name__ == "__main__": - serve("localhost", 5000) + serve("localhost", 8000) diff --git a/docs/examples/sanic_server.py b/docs/examples/sanic_server.py index 5ef95f1..f4447d2 100644 --- a/docs/examples/sanic_server.py +++ b/docs/examples/sanic_server.py @@ -3,6 +3,8 @@ from jsonrpcserver import Result, Success, async_dispatch, method app = Sanic("jsonrpc") +# Bigger requests get 413 Request Entity Too Large. Sanic's default is 100 MB. +app.config.REQUEST_MAX_SIZE = 1_000_000 @method @@ -12,10 +14,11 @@ async def ping() -> Result: @app.post("/") async def index(request: Request) -> HTTPResponse: - if response := await async_dispatch(request.body.decode()): + # max_batch_size: see the Security page. + if response := await async_dispatch(request.body.decode(), max_batch_size=100): return HTTPResponse(response, content_type="application/json") return HTTPResponse(status=204) if __name__ == "__main__": - app.run(host="localhost", port=5000, single_process=True) + app.run(host="localhost", port=8000, single_process=True) diff --git a/docs/examples/socketio_server.py b/docs/examples/socketio_server.py index 86f14ba..b2f899a 100644 --- a/docs/examples/socketio_server.py +++ b/docs/examples/socketio_server.py @@ -4,7 +4,8 @@ from jsonrpcserver import Result, Success, dispatch, method app = Flask(__name__) -socketio = SocketIO(app) +# A bigger message is rejected. 1,000,000 bytes is also the default. +socketio = SocketIO(app, max_http_buffer_size=1_000_000) @method @@ -14,11 +15,12 @@ def ping() -> Result: @socketio.on("message") def handle_message(message: str) -> None: - if response := dispatch(message): + # max_batch_size: see the Security page. + if response := dispatch(message, max_batch_size=100): send(response) if __name__ == "__main__": # Werkzeug's server is for development. See the Flask-SocketIO docs for # production servers. - socketio.run(app, host="localhost", port=5000, allow_unsafe_werkzeug=True) + socketio.run(app, host="localhost", port=8000, allow_unsafe_werkzeug=True) diff --git a/docs/examples/tornado_server.py b/docs/examples/tornado_server.py index 006eba5..3395aaf 100644 --- a/docs/examples/tornado_server.py +++ b/docs/examples/tornado_server.py @@ -12,7 +12,9 @@ async def ping() -> Result: class MainHandler(web.RequestHandler): async def post(self) -> None: - if response := await async_dispatch(self.request.body.decode()): + # max_batch_size: see the Security page. + request = self.request.body.decode() + if response := await async_dispatch(request, max_batch_size=100): self.set_header("Content-Type", "application/json") self.write(response) else: @@ -21,7 +23,8 @@ async def post(self) -> None: async def main() -> None: app = web.Application([(r"/", MainHandler)]) - app.listen(5000, address="localhost") + # Tornado's default limit is 100 MB. + app.listen(8000, address="localhost", max_body_size=1_000_000) await asyncio.Event().wait() diff --git a/docs/examples/websockets_server.py b/docs/examples/websockets_server.py index d2b2bff..38728de 100644 --- a/docs/examples/websockets_server.py +++ b/docs/examples/websockets_server.py @@ -14,12 +14,14 @@ async def handler(websocket: ServerConnection) -> None: async for message in websocket: request = message.decode() if isinstance(message, bytes) else message # Unlike HTTP, there's no need to answer a notification. - if response := await async_dispatch(request): + # max_batch_size: see the Security page. + if response := await async_dispatch(request, max_batch_size=100): await websocket.send(response) async def main() -> None: - async with serve(handler, "localhost", 5000) as server: + # A bigger message closes the connection. The default is 1 MiB. + async with serve(handler, "localhost", 8000, max_size=1_000_000) as server: await server.serve_forever() diff --git a/docs/examples/werkzeug_server.py b/docs/examples/werkzeug_server.py index d456634..6bce829 100644 --- a/docs/examples/werkzeug_server.py +++ b/docs/examples/werkzeug_server.py @@ -4,17 +4,23 @@ from jsonrpcserver import Result, Success, dispatch, method +class JsonRpcRequest(Request): + # Bigger requests get 413 Request Entity Too Large. + max_content_length = 1_000_000 + + @method def ping() -> Result: return Success("pong") -@Request.application -def application(request: Request) -> Response: - if response := dispatch(request.get_data(as_text=True)): +@JsonRpcRequest.application +def application(request: JsonRpcRequest) -> Response: + # max_batch_size: see the Security page. + if response := dispatch(request.get_data(as_text=True), max_batch_size=100): return Response(response, content_type="application/json") return Response(status=204) if __name__ == "__main__": - run_simple("localhost", 5000, application) + run_simple("localhost", 8000, application) diff --git a/docs/examples/zeromq_async_server.py b/docs/examples/zeromq_async_server.py index d10f47d..35f93b2 100644 --- a/docs/examples/zeromq_async_server.py +++ b/docs/examples/zeromq_async_server.py @@ -13,10 +13,13 @@ async def ping() -> Result: async def main() -> None: socket = zmq.asyncio.Context().socket(zmq.REP) - socket.bind("tcp://*:5000") + # A bigger message disconnects the client. The default is no limit. + socket.setsockopt(zmq.MAXMSGSIZE, 1_000_000) + socket.bind("tcp://127.0.0.1:8000") while True: request = await socket.recv_string() - await socket.send_string(await async_dispatch(request)) + # max_batch_size: see the Security page. + await socket.send_string(await async_dispatch(request, max_batch_size=100)) if __name__ == "__main__": diff --git a/docs/examples/zeromq_server.py b/docs/examples/zeromq_server.py index 63deeff..e161f67 100644 --- a/docs/examples/zeromq_server.py +++ b/docs/examples/zeromq_server.py @@ -10,9 +10,11 @@ def ping() -> Result: if __name__ == "__main__": socket = zmq.Context().socket(zmq.REP) - socket.bind("tcp://*:5000") + # A bigger message disconnects the client. The default is no limit. + socket.setsockopt(zmq.MAXMSGSIZE, 1_000_000) + socket.bind("tcp://127.0.0.1:8000") while True: request = socket.recv_string() # A REP socket must reply to every request, so a notification gets an - # empty message. - socket.send_string(dispatch(request)) + # empty message. max_batch_size: see the Security page. + socket.send_string(dispatch(request, max_batch_size=100)) diff --git a/docs/faq.md b/docs/faq.md index faac95c..c09f244 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,25 +1,33 @@ +--- +description: Answers to common jsonrpcserver questions. Internal errors with no details, tracebacks, HTTP status codes, printing responses, threads, orjson and the old domains. +--- + # FAQ -## How do I turn off request validation? +## My method works, but the client gets "Internal error" with no details -Validating each request against the JSON-RPC schema takes about three -quarters of the dispatch time for a trivial method. If you know the requests -are valid, for example because your own code makes them, turn it off: +Either the method raised an exception, or it returned something other than +`Success(...)` or `Error(...)`. The most common case is a method written for +4.x that returns its result directly, such as `return "pong"`. Return +`Success("pong")` instead. See [Migration](migration.md). -```python -from jsonrpcserver import Result, Success, dispatch, method +The details are in your server's log, not in the response. From 5.0.10 that's +on purpose, so that exception messages don't leak to clients. If the log +shows nothing, see the next question. +## How do I see the traceback? -@method -def ping() -> Result: - return Success("pong") +jsonrpcserver logs it on the `jsonrpcserver` logger, but doesn't configure +logging. Configure it when your server starts: +```python +import logging -dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', validator=lambda _: None) +logging.basicConfig(level=logging.INFO) ``` -Without validation, a malformed request can give a less helpful error, but -`dispatch` still won't raise. +While developing, you can also pass `debug=True` to `dispatch` to get the +exception message in the response. See [Errors and logging](errors.md). ## Which HTTP status code should I send? @@ -30,7 +38,21 @@ def status(response: str) -> int: A JSON-RPC error is still a successful HTTP exchange, so send 200 with it. If `dispatch` gives an empty string, the request was a notification and there's -no body, so send 204 No Content. The built-in `serve()` does this. +no body, so send 204 No Content. The built-in `serve()` does this from +5.0.10. + +## Why does printing the result of dispatch_to_response fail? + +`print(dispatch_to_response(...))` raises `TypeError: not all arguments +converted during string formatting`. That's a bug in oslash, the library the +`Response` objects come from. Print `to_dict(response)` instead, or use +`dispatch_to_serializable`, which gives dicts. See +[Dispatch](dispatch.md#dispatch_to_response). + +## How do I get the response as a dict instead of a string? + +Use `dispatch_to_serializable`. See +[other return types](dispatch.md#other-return-types). ## How do I rename a method? @@ -39,20 +61,75 @@ Use `@method(name="new_name")`, or pass your own dict as the ## Can a method get the HTTP request, user or database connection? -Pass it as [context](dispatch.md#context). It becomes the first argument of -every method, and the client can't change it. - -## How do I get the response as a dict instead of a string? - -Use `dispatch_to_serializable`. See -[other return types](dispatch.md#other-return-types). +Pass it as `context`. It becomes the first argument of every method, and the +client can't change it. [Context](context.md) has examples for Flask, FastAPI +and Django. ## Why does my async method give an Internal error? You called it with `dispatch`. Async methods need [`async_dispatch`](async.md). +## Is it thread-safe? + +Yes, including on free-threaded Python. Register your methods at import time. +See [Threads](threads.md). + +## How do I turn off request validation? + +Pass `validator=lambda _: None`. It saves time, but bad requests then get odd +answers. See [Validation](validation.md#turning-it-off). + +## Can I use orjson or ujson? + +Yes, through `deserializer` and `serializer`. ujson's functions fit as they +are. [orjson](https://pypi.org/project/orjson/)'s `dumps` returns bytes, and +`dispatch` must return a string, so decode it: + + +```python +import orjson + +from jsonrpcserver import Result, Success, dispatch + + +def ping() -> Result: + return Success("pong") + + +def orjson_dumps(response: object) -> str: + return orjson.dumps(response).decode() + + +print( + dispatch( + '{"jsonrpc": "2.0", "method": "ping", "id": 1}', + {"ping": ping}, + deserializer=orjson.loads, + serializer=orjson_dumps, + ) +) +``` + +```text title="Output" +{"jsonrpc":"2.0","result":"pong","id":1} +``` + +orjson writes `NaN` and `Infinity` as `null` instead of raising, so a result +that contains them is sent with `null` in their place, not as an error. + ## Where did the examples on the wiki go? -They're on the [Examples](examples.md) page now, and CI runs each one against -a real server. +They're on the [Frameworks](examples.md) pages now, and CI runs each one +against a real server. + +## Where is the old documentation website? + +The project's old domains, jsonrpcserver.com and jsonrpcclient.com, now +belong to someone else. Ignore them and any links to them. The copy at +explodinglabs.com/jsonrpcserver/ is old and no longer updated, and so is the +one on Read the Docs. + +The official places are these docs, the [GitHub +repository](https://github.com/bensynapse/jsonrpcserver) and the [PyPI +project](https://pypi.org/project/jsonrpcserver/). diff --git a/docs/frameworks/aiohttp.md b/docs/frameworks/aiohttp.md new file mode 100644 index 0000000..f3ea23c --- /dev/null +++ b/docs/frameworks/aiohttp.md @@ -0,0 +1,38 @@ +--- +description: A JSON-RPC 2.0 server with aiohttp and jsonrpcserver's async_dispatch, with a request size limit and max_batch_size set. Tested in CI. +--- + +# aiohttp + +```python +--8<-- "docs/examples/aiohttp_server.py" +``` + +aiohttp refuses a body bigger than `client_max_size` with 413. Its default is +1 MiB (1,048,576 bytes). The example sets 1,000,000, like the other examples. +`max_batch_size` limits how many requests one batch can hold. See +[Security](../security.md). + +To give methods the request, pass it as `context`: +`await async_dispatch(..., context=request)`. See [Context](../context.md). + +## Try it + +Save the example as `aiohttp_server.py`, install aiohttp (`pip install aiohttp`), and run it: + +```sh +python aiohttp_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/django.md b/docs/frameworks/django.md new file mode 100644 index 0000000..4075e4d --- /dev/null +++ b/docs/frameworks/django.md @@ -0,0 +1,48 @@ +--- +description: A JSON-RPC 2.0 endpoint in Django with jsonrpcserver, as a whole project in one file, with a request size limit and max_batch_size set. Tested in CI. +--- + +# Django + +A whole Django project in one file. In your project, the view goes in an app +and the URL pattern in its `urls.py`. + +```python +--8<-- "docs/examples/django_server.py" +``` + +Django refuses a body bigger than `DATA_UPLOAD_MAX_MEMORY_SIZE` with 400 Bad +Request when the view reads `request.body`. The default is 2.5 MB, and the +example sets it explicitly. `max_batch_size` limits how many requests one +batch can hold. See [Security](../security.md). + +The view is exempt from CSRF checks, because JSON-RPC clients don't send a +CSRF token. Use another way to authenticate them, such as a token in a header. +To give methods `request.user`, pass it in the context. +[Context](../context.md#django) has an example. + +`runserver` is Django's development server. For production, use a WSGI or +ASGI server, as the +[Django docs](https://docs.djangoproject.com/en/stable/howto/deployment/) +describe. + +## Try it + +Save the example as `django_server.py`, install Django (`pip install django`), and run it: + +```sh +python django_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/fastapi.md b/docs/frameworks/fastapi.md new file mode 100644 index 0000000..503b138 --- /dev/null +++ b/docs/frameworks/fastapi.md @@ -0,0 +1,44 @@ +--- +description: A JSON-RPC 2.0 endpoint in FastAPI with jsonrpcserver's async_dispatch, with a request size limit and max_batch_size set. Tested in CI. +--- + +# FastAPI + +```python +--8<-- "docs/examples/fastapi_server.py" +``` + +FastAPI and Starlette have no limit on the body size, so the endpoint reads +the body in chunks and answers 413 once it's too big. Many deployments also +set a limit in the reverse proxy in front, such as nginx's +`client_max_body_size`. `max_batch_size` limits how many requests one batch +can hold. See [Security](../security.md). + +The methods are `async`, and the endpoint uses `async_dispatch`. Plain methods +work too, but they run on the event loop, so a slow one holds up other +requests. See [Async](../async.md). + +FastAPI's dependency injection doesn't reach into methods. Resolve what they +need in the endpoint and pass it as `context`. +[Context](../context.md#fastapi) has an example. + +## Try it + +Save the example as `fastapi_server.py`, install FastAPI and Uvicorn (`pip install fastapi uvicorn`), and run it: + +```sh +python fastapi_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/flask.md b/docs/frameworks/flask.md new file mode 100644 index 0000000..f6bab73 --- /dev/null +++ b/docs/frameworks/flask.md @@ -0,0 +1,41 @@ +--- +description: A JSON-RPC 2.0 server with Flask and jsonrpcserver, with a request size limit and max_batch_size set. Tested in CI. +--- + +# Flask + +```python +--8<-- "docs/examples/flask_server.py" +``` + +`MAX_CONTENT_LENGTH` makes Flask answer a bigger body with 413 before it's +read. Flask has no limit by default. `max_batch_size` limits how many +requests one batch can hold. See [Security](../security.md). + +To give methods the request or the logged-in user, pass `context`. +[Context](../context.md#flask) has a Flask example. + +`app.run` starts Flask's development server. For production, run the app with +a WSGI server such as Gunicorn, as the +[Flask docs](https://flask.palletsprojects.com/en/stable/deploying/) describe. + +## Try it + +Save the example as `flask_server.py`, install Flask (`pip install flask`), and run it: + +```sh +python flask_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/http-server.md b/docs/frameworks/http-server.md new file mode 100644 index 0000000..d7a0636 --- /dev/null +++ b/docs/frameworks/http-server.md @@ -0,0 +1,71 @@ +--- +description: A JSON-RPC 2.0 server with Python's built-in http.server and jsonrpcserver, with no other dependencies. Plus serve(), the development server that comes with jsonrpcserver. +--- + +# http.server and serve() + +## http.server + +Python's built-in HTTP server, with no other dependencies. It reads the body +itself, so it also has to check the `Content-Length` header, refuse a body +that's too big and handle bytes that aren't UTF-8: + +```python +--8<-- "docs/examples/http_server.py" +``` + +A missing `Content-Length` gets 411, a body over the limit gets 413, and a +body that isn't UTF-8 gets a -32700 "Parse error". http.server is fine for +small internal tools. For anything public, use a framework or put a +production web server in front. + +## serve() + +jsonrpcserver also has a small built-in server, `serve()`. It's for trying +things out, not for production: + +```python +--8<-- "docs/examples/quickstart.py" +``` + +It answers POST requests on any path, sends 204 for notifications, and +handles each request in its own thread. It also handles a missing +`Content-Length` (411) and a body that isn't UTF-8 (-32700). + +Know its limits before you use it: + +- It listens on every network interface unless you pass a host, as the + example does with `"localhost"`. With `serve()` and no arguments, anyone + who can reach your machine on port 5000 can call your methods. +- It has no TLS, no authentication and no limit on the body size, and it + doesn't set `max_batch_size`. +- It says where it's listening when it starts (new in 5.0.10). Each request + is logged on the `jsonrpcserver.server` logger at INFO level, so + `logging.basicConfig(level=logging.INFO)` shows them. + +!!! info "Changed in 5.0.10" + In 5.0.9, `serve()` answers a notification with 200 and an empty body + instead of 204. It also handles one request at a time. It logs its start on + the root logger at INFO, so you usually see nothing. It drops the + connection for a missing `Content-Length` or a body that isn't UTF-8. + +## Try it + +Save the serve() example as `quickstart.py` and run it: + +```sh +python quickstart.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/sanic.md b/docs/frameworks/sanic.md new file mode 100644 index 0000000..5802ea4 --- /dev/null +++ b/docs/frameworks/sanic.md @@ -0,0 +1,40 @@ +--- +description: A JSON-RPC 2.0 server with Sanic and jsonrpcserver's async_dispatch, with a request size limit and max_batch_size set. Tested in CI. +--- + +# Sanic + +```python +--8<-- "docs/examples/sanic_server.py" +``` + +Sanic refuses a body bigger than `REQUEST_MAX_SIZE` with 413. Its default is +100 MB, so the example sets a smaller one. `max_batch_size` limits how many +requests one batch can hold. See [Security](../security.md). + +To give methods the request, pass it as `context`: +`await async_dispatch(..., context=request)`. See [Context](../context.md). + +`single_process=True` keeps the example simple. Sanic's own docs cover +running it with workers in production. + +## Try it + +Save the example as `sanic_server.py`, install Sanic (`pip install sanic`), and run it: + +```sh +python sanic_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/socketio.md b/docs/frameworks/socketio.md new file mode 100644 index 0000000..0364a2e --- /dev/null +++ b/docs/frameworks/socketio.md @@ -0,0 +1,28 @@ +--- +description: A JSON-RPC 2.0 server over Socket.IO with Flask-SocketIO and jsonrpcserver, with a message size limit and max_batch_size set. Tested in CI. +--- + +# Socket.IO + +Using [Flask-SocketIO](https://flask-socketio.readthedocs.io/). Requests +arrive as `message` events, and responses go back the same way. + +```python +--8<-- "docs/examples/socketio_server.py" +``` + +A notification gets nothing back. Flask-SocketIO refuses a message bigger +than `max_http_buffer_size`. Its default is 1,000,000 bytes, and the example +sets it explicitly. `max_batch_size` limits how many requests one batch can +hold. See [Security](../security.md). + +The example runs on Werkzeug's development server. Flask-SocketIO's docs +cover the production servers it supports. + +## Try it + +Install Flask-SocketIO (`pip install flask-socketio`) and run the example. +Any Socket.IO client can call it: connect to `http://localhost:8000`, emit a +`message` event with the request string, and wait for a `message` event with +the response. With [python-socketio](https://python-socketio.readthedocs.io/), +that's `SimpleClient.connect`, `emit("message", request)` and `receive()`. diff --git a/docs/frameworks/tornado.md b/docs/frameworks/tornado.md new file mode 100644 index 0000000..13c66a0 --- /dev/null +++ b/docs/frameworks/tornado.md @@ -0,0 +1,37 @@ +--- +description: A JSON-RPC 2.0 server with Tornado and jsonrpcserver's async_dispatch, with a request size limit and max_batch_size set. Tested in CI. +--- + +# Tornado + +```python +--8<-- "docs/examples/tornado_server.py" +``` + +Tornado refuses a body bigger than `max_body_size` with 400. Its default is +100 MB, so the example passes a smaller one to `listen`. `max_batch_size` +limits how many requests one batch can hold. See [Security](../security.md). + +To give methods the request, pass `self.request` as `context`. See +[Context](../context.md). + +## Try it + +Save the example as `tornado_server.py`, install Tornado (`pip install tornado`), and run it: + +```sh +python tornado_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/websockets.md b/docs/frameworks/websockets.md new file mode 100644 index 0000000..ebf8481 --- /dev/null +++ b/docs/frameworks/websockets.md @@ -0,0 +1,38 @@ +--- +description: A JSON-RPC 2.0 server over WebSocket with the websockets library and jsonrpcserver's async_dispatch, with a message size limit and max_batch_size set. Tested in CI. +--- + +# websockets + +Uses the `websockets.asyncio` server from +[websockets](https://websockets.readthedocs.io/) 13 and later. + +```python +--8<-- "docs/examples/websockets_server.py" +``` + +Unlike HTTP, a WebSocket doesn't need an answer to every message, so a +notification gets nothing back. websockets closes the connection if a message +is bigger than `max_size`. Its default is 1 MiB (1,048,576 bytes). The +example sets 1,000,000, like the other examples. `max_batch_size` limits how many requests one batch can hold. See +[Security](../security.md). + +Requests on one connection are handled one at a time, in the order they +arrive. To answer them concurrently, start a task for each message. + +## Try it + +Save the example as `websockets_server.py`, install websockets +(`pip install websockets`), and run it: + +```sh +python websockets_server.py +``` + +websockets comes with an interactive client. Run +`python -m websockets ws://localhost:8000/` in another terminal and type a +request, such as `{"jsonrpc": "2.0", "method": "ping", "id": 1}`. + +From Python, jsonrpcclient's +[websockets example](https://bensynapse.github.io/jsonrpcclient/transports/websockets/) +calls this server. diff --git a/docs/frameworks/werkzeug.md b/docs/frameworks/werkzeug.md new file mode 100644 index 0000000..e7e50c0 --- /dev/null +++ b/docs/frameworks/werkzeug.md @@ -0,0 +1,43 @@ +--- +description: A JSON-RPC 2.0 server with Werkzeug and jsonrpcserver, with a request size limit and max_batch_size set. Tested in CI. +--- + +# Werkzeug + +[Werkzeug](https://werkzeug.palletsprojects.com/) is the WSGI toolkit under +Flask. Use it on its own for a small WSGI app: + +```python +--8<-- "docs/examples/werkzeug_server.py" +``` + +Setting `max_content_length` on a `Request` subclass makes `get_data` refuse +a bigger body with 413. `max_batch_size` limits how many requests one batch +can hold. See [Security](../security.md). + +To give methods the request, pass it as `context`: +`dispatch(..., context=request)`. See [Context](../context.md). + +`run_simple` is Werkzeug's development server. For production, use a WSGI +server such as Gunicorn. + +## Try it + +Save the example as `werkzeug_server.py`, install Werkzeug (`pip install werkzeug`), and run it: + +```sh +python werkzeug_server.py +``` + +Then send it a request from another terminal: + +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} +``` + +The [jsonrpcclient quickstart](https://bensynapse.github.io/jsonrpcclient/#quickstart) +sends the same request from Python. diff --git a/docs/frameworks/zeromq.md b/docs/frameworks/zeromq.md new file mode 100644 index 0000000..95173c9 --- /dev/null +++ b/docs/frameworks/zeromq.md @@ -0,0 +1,43 @@ +--- +description: A JSON-RPC 2.0 server over ZeroMQ with pyzmq and jsonrpcserver, sync and asyncio, with a message size limit and max_batch_size set. Tested in CI. +--- + +# ZeroMQ + +Using [pyzmq](https://pyzmq.readthedocs.io/) with a REP socket, which +answers one request at a time. + +## Sync + +```python +--8<-- "docs/examples/zeromq_server.py" +``` + +## asyncio + +With pyzmq's `zmq.asyncio`: + +```python +--8<-- "docs/examples/zeromq_async_server.py" +``` + +## Notes + +A REP socket must send a reply for every message it receives, before it can +receive the next one. So a notification gets an empty message back, which +the client should ignore. + +`MAXMSGSIZE` makes ZeroMQ drop a client that sends a bigger message. There's +no limit by default. `max_batch_size` limits how many requests one batch can +hold. See [Security](../security.md). + +The examples bind to `127.0.0.1`. `tcp://*:8000` would listen on every +network interface, and ZeroMQ has no authentication unless you set up one of +its security mechanisms, such as CURVE. + +## Try it + +Install pyzmq (`pip install pyzmq`) and run one of the examples. From Python, +jsonrpcclient's +[ZeroMQ example](https://bensynapse.github.io/jsonrpcclient/transports/zeromq/) +calls this server. diff --git a/docs/index.md b/docs/index.md index 1fc433c..776d6f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,65 +1,108 @@ - +--- +title: Process incoming JSON-RPC 2.0 requests in Python +description: jsonrpcserver turns JSON-RPC 2.0 requests into calls to your Python functions. It works with any framework or transport, sync or async, and is typed and thread-safe. +--- # jsonrpcserver -![jsonrpcserver](assets/logo.png) - -_Process incoming JSON-RPC requests in Python._ +_Process incoming JSON-RPC 2.0 requests in Python._ jsonrpcserver takes a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) -request string, calls your function and gives you the response string. It -doesn't listen on a port itself, so it works with any framework or transport: -HTTP, websockets, ZeroMQ, message queues and so on. +request, calls your Python function and gives you the response to send back. +It leaves the networking to you, so it works with any framework or transport: +Flask, Django, FastAPI, aiohttp, websockets, ZeroMQ and more. It runs sync and +async methods, ships type hints, and is safe to use from several threads. It +supports Python 3.8 to 3.14, including free-threaded 3.14t. -## Installation +## Install ```sh pip install jsonrpcserver ``` -It supports Python 3.8 and later. - -## Quick start +## Quickstart -Write a method: +Save this as `server.py`: ```python -from jsonrpcserver import Result, Success, dispatch, method +--8<-- "docs/examples/quickstart.py" +``` + +Run it with `python server.py` and leave it running. `serve()` is a small +server for trying things out. In production you'd use your web framework +instead, as in the [framework examples](examples.md). +In another terminal, send it a request with curl: -@method -def ping() -> Result: - return Success("pong") +```sh +curl -s -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "method": "ping", "id": 1}' http://localhost:8000/ +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": "pong", "id": 1} ``` -Then pass the request to `dispatch`: +Or call it from Python with +[jsonrpcclient](https://bensynapse.github.io/jsonrpcclient/), the companion +library for the client side (`pip install jsonrpcclient requests`): + + ```python +import requests +from jsonrpcclient import Error, Ok, parse, request + +url = "http://localhost:8000/" +response = requests.post(url, json=request("ping"), timeout=10) +response.raise_for_status() +parsed = parse(response.json()) +if isinstance(parsed, Ok): + print(parsed.result) +elif isinstance(parsed, Error): + print("Error:", parsed.message) +``` + +```text title="Output" +pong +``` + +### What happens inside + +The server passes each request body to `dispatch`, which calls your method and +gives back the response as a string: + +```pycon +>>> from jsonrpcserver import Result, Success, dispatch, method +>>> @method +... def ping() -> Result: +... return Success("pong") >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') '{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` -Send that string back to the client. For a notification (a request without an -`id`), `dispatch` gives an empty string, meaning there's nothing to send: +That's all there is to it in your own framework: pass the request body to +`dispatch` and send back what it gives you. A notification (a request with no +`id`) gives an empty string, which means there's nothing to send: -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "ping"}') '' ``` -## Next - -- [Methods](methods.md): writing methods, parameters and errors. -- [Dispatch](dispatch.md): the options `dispatch` takes. -- [Async](async.md): `async_dispatch` for asyncio servers. -- [Examples](examples.md): Flask, FastAPI, Django, aiohttp, websockets, - ZeroMQ and more. -- [Security](security.md): what to set before you expose a server. -- [FAQ](faq.md) - -jsonrpcclient, the companion library for the other end of the connection, is -at [bensynapse/jsonrpcclient](https://github.com/bensynapse/jsonrpcclient). +Over HTTP, answer that with status 204 and no body. + +## Where next + +- [Methods](methods.md) and [Dispatch](dispatch.md): writing methods, and the + options `dispatch` takes. +- [Frameworks](examples.md): complete, tested examples for http.server, + Flask, Werkzeug, Django, FastAPI, aiohttp, Sanic, Tornado, websockets, ZeroMQ + and Socket.IO. +- [Security](security.md): what to set before you put a server on the + internet. +- [Errors and logging](errors.md): every error the client can get, and where + the details go. +- [API reference](reference.md): every function and class, with signatures. +- [Migrating from 4.x](migration.md): methods must now return `Success(...)`. +- [jsonrpcclient](https://bensynapse.github.io/jsonrpcclient/): the same idea + for the client side. diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..1a2de9d --- /dev/null +++ b/docs/license.md @@ -0,0 +1,12 @@ +--- +description: jsonrpcserver is released under the MIT License. It was created by Beau Barker and is maintained by Synapse Research. +--- + +# License + +jsonrpcserver is released under the MIT License. It was created by Beau +Barker and is maintained by [Synapse Research](https://synapsereality.io). + +```text +--8<-- "LICENSE" +``` diff --git a/docs/methods.md b/docs/methods.md index 2b64359..78d4a10 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -1,7 +1,11 @@ +--- +description: Write JSON-RPC methods with jsonrpcserver. The @method decorator, renaming, returning Success or Error, raising JsonRpcError, and how parameters are checked. +--- + # Methods Methods are the functions a JSON-RPC request can call. To write one, decorate -a function with `@method`: +a function with `@method`, and return `Success` with the result: ```python from jsonrpcserver import Error, InvalidParams, Result, Success, dispatch, method @@ -24,13 +28,18 @@ def add_numbers(a: int, b: int) -> Result: return Success(a + b) ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "sum", "params": [2, 3], "id": 1}') '{"jsonrpc": "2.0", "result": 5, "id": 1}' ``` -If you'd rather not use a decorator, pass the methods to `dispatch` yourself. -See [methods](dispatch.md#methods) on the Dispatch page. +A later `@method` with the same name replaces the earlier one, without a +warning. If you'd rather not use a decorator, pass the methods to `dispatch` +yourself as a dict. See [methods](dispatch.md#methods) on the Dispatch page. + +!!! info "New in 5.0.10" + A name that starts with `rpc.` gives a `UserWarning`, because the + JSON-RPC spec reserves those names. The method is still added. ## Results @@ -38,6 +47,12 @@ A method returns `Success` or `Error`. They are the `result` and `error` parts of a [JSON-RPC response](https://www.jsonrpc.org/specification#response_object). jsonrpcserver adds the `jsonrpc` and `id` parts. +!!! warning "Return `Success(value)`, not the value" + In 4.x a method returned its result directly. In 5.x, `return "pong"` + sends the client a -32603 "Internal error". From 5.0.10 the response + has no details, and the log says what went wrong. See + [Migration](migration.md). + `Success` takes the result value. If there's nothing to return, call it with no argument and the result is `null`: @@ -47,13 +62,12 @@ def log(message: str) -> Result: return Success() ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "log", "params": ["hi"], "id": 1}') '{"jsonrpc": "2.0", "result": null, "id": 1}' ``` -`Error` takes a code, a message and, optionally, some data. The code must be -an integer and the message a string. +`Error` takes a code, a message and, optionally, some data: ```python @method @@ -63,11 +77,20 @@ def divide(a: float, b: float) -> Result: return Success(a / b) ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "divide", "params": [1, 0], "id": 1}') '{"jsonrpc": "2.0", "error": {"code": 1, "message": "Can\'t divide by zero", "data": {"a": 1}}, "id": 1}' ``` +The spec says the code must be an integer and the message should be a string. +The spec reserves the codes from -32768 to -32000 for its own errors, so pick +other numbers for yours. + +!!! info "New in 5.0.10" + `Error` and `JsonRpcError` give a `UserWarning` if the code isn't an + integer or the message isn't a string. The error is still sent as given, + so existing code keeps working, but some clients can't read it. + You can also raise `JsonRpcError`, which takes the same arguments as `Error`. That's handy deep inside other functions: @@ -86,13 +109,14 @@ def square_root(number: float) -> Result: return Success(number**0.5) ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "square_root", "params": [-4], "id": 1}') '{"jsonrpc": "2.0", "error": {"code": 2, "message": "Must be positive"}, "id": 1}' ``` Any other exception gives a -32603 "Internal error" response. The exception -isn't sent to the client, but it is logged. See [Security](security.md). +isn't sent to the client, but it is logged. See +[Errors and logging](errors.md). ## Parameters @@ -105,22 +129,29 @@ def hello(name: str) -> Result: return Success("Hello " + name) ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": ["Beau"], "id": 1}') '{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' >>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": {"name": "Beau"}, "id": 1}') '{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' ``` -If they don't fit, the client gets -32602 "Invalid params": +If they don't fit, the client gets -32602 "Invalid params", with Python's +explanation in `data`: -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": [], "id": 1}') '{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": "missing a required argument: \'name\'"}, "id": 1}' ``` +A request's `params` is either a list or an object, so a method can't get some +arguments by position and others by name. That's a JSON-RPC rule. + jsonrpcserver doesn't check the types of the values. A method gets whatever -JSON gave: `str`, `int`, `float`, `bool`, `None`, `list` or `dict`. +JSON gave: `str`, `int`, `float`, `bool`, `None`, `list` or `dict`. The client +also chooses which of your parameters to fill, including ones with default +values. [Security](security.md#every-parameter-is-up-to-the-client) explains +why that matters. ## Invalid params @@ -135,22 +166,18 @@ def rate(stars: int) -> Result: return Success() ``` -```python +```pycon >>> dispatch('{"jsonrpc": "2.0", "method": "rate", "params": [6], "id": 1}') '{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": "Stars must be 1 to 5"}, "id": 1}' ``` -## Type checking +## Async methods -`@method` keeps the function's signature, so mypy and pyright check calls to -your methods as usual. +Methods can be `async def` functions too. Call them with `async_dispatch` +instead of `dispatch`. See [Async](async.md). -`Result` comes from the oslash library, which has type hints but no `py.typed` -marker. pyright reads them anyway. mypy treats `Result` as `Any` unless you add -this to `pyproject.toml`: +## Type checking -```toml -[[tool.mypy.overrides]] -module = ["oslash", "oslash.*"] -follow_untyped_imports = true -``` +`@method` keeps the function's signature, so mypy and pyright check your +methods and the calls to them. [Typing](typing.md) has the details, including +one setting mypy needs. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..fc68ef2 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,168 @@ +--- +description: Upgrade jsonrpcserver from 4.x to 5.x, and from 5.0.9 to 5.0.10. Side-by-side examples, removed options, and the plain return value that silently becomes an Internal error. +--- + +# Migration + +## From 4.x to 5.x + +Version 5 changed how methods report their result. Most other code needs only +small changes. + +!!! danger "Old methods fail with no details" + In 4.x a method returned its result directly. In 5.x it must return + `Success(result)`. A 4.x method such as + + + ```python + @method + def ping(): + return "pong" + ``` + + still runs, but the client gets a -32603 "Internal error". Up to 5.0.9, + its `data` says "The method did not return a valid Result". From 5.0.10 + the client gets no details, and the log says what's wrong: + + ```text + Method 'ping' returned 'pong', which is not a Result, so the client got an Internal error. Return Success(value) or Error(code, message). ... + ``` + + If you don't see that line, configure logging (see + [Errors and logging](errors.md#logging)). Then search your code for + `return` statements in methods. mypy and pyright catch them too, once + methods are annotated `-> Result` (see [Typing](typing.md)). + +### A method, before and after + +4.x: + + +```python +# jsonrpcserver 4.x. This doesn't work on 5.x. +from jsonrpcserver import dispatch, method +from jsonrpcserver.exceptions import ApiError, InvalidParamsError + + +@method +def divide(a, b): + if not isinstance(b, (int, float)): + raise InvalidParamsError("b must be a number") + if b == 0: + raise ApiError("Can't divide by zero", code=1) + return a / b + + +response = dispatch(request_body) +if response.wanted: + send(str(response), status=response.http_status) +``` + +5.x: + +```python +from jsonrpcserver import Error, InvalidParams, Result, Success, dispatch, method + + +@method +def divide(a: float, b: float) -> Result: + if not isinstance(b, (int, float)): + return InvalidParams("b must be a number") + if b == 0: + return Error(1, "Can't divide by zero") + return Success(a / b) + + +response = dispatch('{"jsonrpc": "2.0", "method": "divide", "params": [1, 0], "id": 1}') +print(response, 200 if response else 204) +``` + +```text title="Output" +{"jsonrpc": "2.0", "error": {"code": 1, "message": "Can't divide by zero"}, "id": 1} 200 +``` + +### What changed + +| 4.x | 5.x | +|---|---| +| `return value` | `return Success(value)` | +| `raise ApiError(message, code=1, data=...)` | `return Error(code, message, data)`, or `raise JsonRpcError(code, message, data)`. Note the order: code first. | +| `raise InvalidParamsError(...)` | `return InvalidParams(data)` | +| `raise MethodNotFoundError` | there's no equivalent. jsonrpcserver sends "Method not found" itself | +| `dispatch` returns a `Response` object | `dispatch` returns a string. `dispatch_to_serializable` gives a dict | +| `str(response)` | `response` is already the string | +| `response.wanted` | `if response:`, since a notification gives `""` | +| `response.http_status` | `200 if response else 204`. Errors are sent with 200 too | +| `methods = Methods(ping, add)`, `methods.add(...)` | a plain dict, `{"ping": ping, "add": add}`, or `@method` | +| `dispatch(..., serialize=..., deserialize=...)` | `serializer=` and `deserializer=` | +| `convert_camel_case=True` | removed. Name your methods and parameters as clients call them | +| `basic_logging=True`, `trim_log_values=True` | removed. Configure the `jsonrpcserver` logger yourself | +| a `.jsonrpcserverrc` config file | removed. Pass options to `dispatch` | +| `debug=True` | removed in 5.0.0, so 5.0.0 to 5.0.9 always send exception messages to the client. Back in 5.0.10, where it works as in 4.x: off by default | + +Code written for 4.x that 5.x can't run gives clear errors in most cases. The +removed keywords raise `TypeError`, and `response.wanted` raises +`AttributeError: 'str' object has no attribute 'wanted'`. The plain return +value above is the one that fails quietly, and `str(response)` still works but +is no longer needed. + +The 4.x documentation is no longer online. The [changelog](changelog.md) +lists every 4.x change. + +## From 5.0.9 to 5.0.10 + +Most code needs no change. These are the differences you might notice. + +**Exception messages are no longer sent.** A method that raises an exception +it doesn't catch gives a -32603 "Internal error" with no `data`. Clients that +read `error.data` for those errors get nothing now. The exception is logged. +Pass `debug=True` in development to get the message back in the response. See +[Security](security.md). + +**Custom validators see one request at a time.** In a batch, the `validator` +is called once for each request, with just that request. In 5.0.9 it was +called once with the whole list. A validator that enforced a rule about the +whole batch, such as a size limit, silently stops doing it. Use +`max_batch_size` instead. See [Validation](validation.md). + +**Batches are handled per request.** A batch that mixes valid and invalid +requests used to get one "Invalid request" for the whole batch. Now each +invalid request gets its own error, and the valid ones run. See +[Notifications and batches](batches.md#invalid-members). + +**NaN and Infinity give an error.** A result that contains `NaN`, `Infinity` +or `-Infinity` now gives an Internal error, because they aren't valid JSON. To +send them anyway, pass `serializer=json.dumps`. + +**A result that can't be serialized gives an error.** For example a +`datetime`. In 5.0.9 `dispatch` raised `TypeError`. Now that response becomes +an Internal error, the error is logged, and the rest of a batch is sent. + +**New warnings.** `Error` and `JsonRpcError` warn when the code isn't an +integer or the message isn't a string, and `@method` warns about names that +start with `rpc.`. The responses are the same as before. If your tests turn +warnings into errors, fix the code or the names. See +[Errors and logging](errors.md#spec-warnings). + +**Deprecated names.** In `jsonrpcserver.response`, `serialize_error`, +`serialize_success` and `to_serializable_one` give a `DeprecationWarning`. Use +`to_error_dict`, `to_success_dict` and `to_dict`. `ResponseType` is kept, but +use `Response`. They will be removed in 6.0. + +**New loggers and log lines.** A serializer failure is logged on +`jsonrpcserver.main`, and `serve()` logs where it's listening. A method that +returns a plain value is logged with a hint instead of a traceback. See +[Errors and logging](errors.md#logging). + +**Plain methods work with `async_dispatch`.** In 5.0.9 they gave an Internal +error. + +**`serve()` sends 204 for a notification**, instead of 200 with an empty +body, and answers bad requests instead of dropping the connection. + +**New features you can use:** `max_batch_size` and `debug` on every dispatch +function, `jsonrpcserver.__version__`, and type checking that sees through +`@method` (see [Typing](typing.md)). + +**Python 3.8 or later.** 5.0.10's package metadata says so, so older Pythons +keep installing 5.0.9. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..c824a74 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,116 @@ +--- +description: API reference for jsonrpcserver, generated from the source code. Every public function and class, with signatures, parameters, return values and exceptions. +--- + +# API reference + +This page is generated from the docstrings in the source code, so it matches +the code on the main branch. + +Everything in the first five sections can be imported from the package +itself, for example `from jsonrpcserver import Success, dispatch, method`. +The two `dispatch_to_json` entries are the exception: they're the functions +behind `dispatch` and `async_dispatch`, and live in `jsonrpcserver.main` and +`jsonrpcserver.async_main`. The other public names are in `jsonrpcserver.response`, +`jsonrpcserver.result`, `jsonrpcserver.codes`, `jsonrpcserver.methods` and +`jsonrpcserver.sentinels`. Anything not listed here is internal and can change +in any release. + +In the signatures, `context=NOCONTEXT` means "no context": methods get only +the request's params. `data=NODATA` means the error has no `data` member. + +## Methods and results + +::: jsonrpcserver.method + +::: jsonrpcserver.Success + +::: jsonrpcserver.Error + +::: jsonrpcserver.InvalidParams + +::: jsonrpcserver.JsonRpcError + +::: jsonrpcserver.Result + +## Dispatch + +::: jsonrpcserver.dispatch + +::: jsonrpcserver.main.dispatch_to_json + options: + show_root_full_path: true + +::: jsonrpcserver.dispatch_to_serializable + +::: jsonrpcserver.dispatch_to_response + +## Async dispatch + +::: jsonrpcserver.async_dispatch + +::: jsonrpcserver.async_main.dispatch_to_json + options: + show_root_full_path: true + +::: jsonrpcserver.async_dispatch_to_serializable + +::: jsonrpcserver.async_dispatch_to_response + +## Development server + +::: jsonrpcserver.serve + +## Version + +::: jsonrpcserver.__version__ + +## Responses + +::: jsonrpcserver.response.Response + +::: jsonrpcserver.response.SuccessResponse + +::: jsonrpcserver.response.ErrorResponse + +::: jsonrpcserver.response.to_dict + +::: jsonrpcserver.response.to_success_dict + +::: jsonrpcserver.response.to_error_dict + +::: jsonrpcserver.response.to_serializable + +## Results + +::: jsonrpcserver.result.SuccessResult + +::: jsonrpcserver.result.ErrorResult + +## Error codes + +::: jsonrpcserver.codes + options: + show_root_heading: false + show_root_toc_entry: false + +## Other names + +::: jsonrpcserver.methods.global_methods + +::: jsonrpcserver.sentinels.NODATA + +::: jsonrpcserver.sentinels.NOCONTEXT + +## Deprecated + +These still work in 5.x and will be removed in 6.0. The three functions give +a `DeprecationWarning`. `ResponseType` gives none, but use `Response`. + +::: jsonrpcserver.response.serialize_error + +::: jsonrpcserver.response.serialize_success + +::: jsonrpcserver.response.to_serializable_one + +::: jsonrpcserver.response.ResponseType diff --git a/docs/roadmap.md b/docs/roadmap.md index f3da415..a9ec1dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,17 @@ +--- +description: The plan for jsonrpcserver 6.0. Results without oslash, safer defaults, a faster validator, and cleanups. 5.x keeps its API and gets fixes. +--- + # Roadmap This is the plan for the next major version. Nothing here is released yet, and it may change. 5.x keeps its current API, and gets bug and security fixes. Discussion happens in the -[issues](https://github.com/bensynapse/jsonrpcserver/issues) and in the draft -[6.0 pull request (#255)](https://github.com/bensynapse/jsonrpcserver/pull/255). +[issues](https://github.com/bensynapse/jsonrpcserver/issues) and +[discussions](https://github.com/bensynapse/jsonrpcserver/discussions). An +older draft of 6.0 exists as a pull request from 2022. It's out of date, and +the plan below replaces it. ## 6.0 @@ -13,10 +19,11 @@ These change behaviour or remove things, so they wait for a major version. **Results without oslash.** `Success`, `Error` and the dispatch functions are built on the oslash library. Its last release for Python 3.8 to 3.11 came out -in 2020, and its newer releases need Python 3.12. 6.0 will replace it with plain typed -result classes, or with `returns` as #255 started. Code that only uses -`Success`, `Error` and `dispatch` should keep working. Code that inspects the -`Left` and `Right` objects from `dispatch_to_response` will need changes. +in 2020, and its newer releases need Python 3.12. 6.0 will replace it with +small typed result classes of its own, or with another maintained library. +Code that only uses `Success`, `Error` and `dispatch` should keep working. +Code that inspects the `Left` and `Right` objects from `dispatch_to_response` +will need changes. **Safer defaults.** @@ -36,9 +43,9 @@ five request fields would be faster and lighter. - Remove `serialize_error`, `serialize_success` and `to_serializable_one`, which 5.0.10 deprecated. -- Decide whether async methods need their own decorator, as #255 proposes, - or whether `async_dispatch` keeps accepting both kinds. Use the same option - names in the sync and async functions. +- Decide whether async methods need their own decorator, or whether + `async_dispatch` keeps accepting both kinds. Use the same option names and + return types in the sync and async functions. - Rework or remove the built-in `serve()` development server. - Drop Python versions that have reached end of life. diff --git a/docs/security-policy.md b/docs/security-policy.md new file mode 100644 index 0000000..d1923db --- /dev/null +++ b/docs/security-policy.md @@ -0,0 +1,5 @@ +--- +description: How to report a security problem in jsonrpcserver privately, and which versions get fixes. +--- + +--8<-- "SECURITY.md" diff --git a/docs/security.md b/docs/security.md index 2731747..4cd3dc2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,18 +1,85 @@ +--- +description: What to set before you expose a jsonrpcserver server to the internet. Exception messages, batch and body size limits, client-controlled parameters, the methods you expose, and a fix for 5.0.9. +--- + # Security jsonrpcserver handles requests from clients you may not trust. These are the things to know before you put a server on the internet. -## Exception messages stay on the server +## If you are on 5.0.9 + +!!! danger "5.0.9 sends exception messages to the client" + These docs describe 5.0.10, which isn't on PyPI yet. + `pip install jsonrpcserver` gives you 5.0.9. In 5.0.9, when a method + raises an exception it doesn't catch, the client gets the exception's + message in `error.data`. Messages from database drivers and HTTP clients + often contain connection strings, passwords, hostnames, file paths or SQL. + + Check your version with `pip show jsonrpcserver`. + +Until you can upgrade, wrap each method so that it catches unexpected +exceptions itself, logs them and returns a plain Internal error: + +```python +import functools +import logging +from typing import Any, Callable + +from jsonrpcserver import Error, JsonRpcError, Result, Success, dispatch, method + +logger = logging.getLogger(__name__) + + +def no_leak(func: Callable[..., Result]) -> Callable[..., Result]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Result: + try: + return func(*args, **kwargs) + except JsonRpcError: + raise # Errors you raise on purpose still reach the client. + except Exception: + logger.exception("Method %s failed", func.__name__) + return Error(-32603, "Internal error") + + return wrapper -If a method raises an exception it doesn't catch, the client gets a -32603 -"Internal error" with no `data`. The exception and its traceback are logged -through the `jsonrpcserver.dispatcher` logger (`jsonrpcserver.async_dispatcher` -for `async_dispatch`), so make sure your logging configuration keeps them. -Before 5.0.10, the exception message was sent to the client. Messages from -database drivers and HTTP clients often contain connection strings, passwords, -hostnames, file paths or SQL. Upgrade if you're on an older version. +@method +@no_leak +def get_user(user_id: int) -> Result: + raise ConnectionError("could not connect to postgres://admin:hunter2@db") +``` + +```pycon +>>> logging.disable(logging.CRITICAL) # Keep the logged traceback out of this page. +>>> dispatch('{"jsonrpc": "2.0", "method": "get_user", "params": [1], "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error"}, "id": 1}' +>>> logging.disable(logging.NOTSET) +``` + +Put `@no_leak` under `@method`, on every method. `functools.wraps` keeps the +function's signature, so jsonrpcserver still checks the params against it. For +async methods, write the same wrapper with `async def` and `await`. + +The wrapper can't cover one case. An exception inside jsonrpcserver itself, +outside your method, still sends its message in a -32000 "Server error". That needs a bug in jsonrpcserver or in a custom +`validator`, so it's rare. Upgrade to 5.0.10 when it's out, and then remove +the wrapper. + +5.0.9 also lacks `max_batch_size`, so limit the request body size in your web +server (see below). It sends `NaN` and `Infinity` in responses, which strict +JSON parsers reject. And when a result can't be serialized, such as a +`datetime`, `dispatch` raises `TypeError` instead of sending an error, so your +framework answers with its own error page. + +## Exception messages stay on the server + +From 5.0.10, if a method raises an exception it doesn't catch, the client +gets a -32603 "Internal error" with no `data`. The exception and its +traceback are logged, on the `jsonrpcserver` logger. Make sure your logging +configuration keeps them. [Errors and logging](errors.md#logging) lists the +loggers. `debug=True` puts the message back in the response. Use it only in development. @@ -20,22 +87,46 @@ development. Errors you return on purpose with `Error`, `InvalidParams` or `JsonRpcError` are sent as they are, so don't put secrets in their `data` either. +## What does reach the client + +Some error responses always carry details, `debug` or not: + +- **-32700 Parse error** carries the deserializer's exception message. With + the default `json.loads`, that's harmless. A custom `deserializer` must not + put secrets in its exception messages. +- **-32602 Invalid params** carries Python's explanation of why the params + don't fit, which names your parameters, such as + `got an unexpected keyword argument 'skip_checks'`. A client can use it to + discover parameter names. +- **-32601 Method not found** repeats the method name the client sent. + +[Errors and logging](errors.md) lists every error. + ## Limit batch size A single request can be a batch of thousands of requests. Each one is -validated and run. With `async_dispatch`, they all run at once. A 5 MB batch of -100,000 pings took about 4 seconds of CPU time in one test. If the methods do -I/O, a batch also multiplies the load on your database or the APIs you call. +validated and run. With `async_dispatch`, they all run at once. In one test, +on a laptop with Python 3.13, a 5 MB batch of 100,000 pings took about 4 +seconds of CPU time. If the methods do I/O, a batch also multiplies the load +on your database or the APIs you call. Set `max_batch_size` on every dispatch call: ```python -from jsonrpcserver import dispatch - response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', max_batch_size=100) ``` -Also limit the size of the request body in your web server or framework. +A bigger batch gets a single error response, and none of it runs. See +[Notifications and batches](batches.md#limit-the-batch-size). It's new in +5.0.10. + +## Limit the request body + +Also limit the size of the request body, in your web server or framework. +It stops a huge request before it's read into memory and parsed, which +`max_batch_size` can't do. Each [framework example](examples.md) sets a limit +of 1,000,000 bytes and names the setting it uses. Some frameworks have no +limit by default, and others allow 100 MB. ## Every parameter is up to the client @@ -44,9 +135,6 @@ your method has. That includes keyword-only parameters and ones with default values. So this is unsafe: ```python -from jsonrpcserver import Result, Success, method - - @method def transfer(amount: int, *, skip_checks: bool = False) -> Result: ... @@ -54,8 +142,9 @@ def transfer(amount: int, *, skip_checks: bool = False) -> Result: ``` A client can send `{"amount": 100, "skip_checks": true}`. Keep server-side -options out of a method's signature. Pass them through `context`, which the -client can't set, or use a separate function. +options out of a method's signature. Pass them through +[`context`](context.md), which the client can't set, or use a separate +function. The values themselves are whatever the JSON held. jsonrpcserver doesn't check them against your type hints, so check them in the method. @@ -70,9 +159,6 @@ name replaces the earlier one without a warning. For a public server, consider passing an explicit dict: ```python -from jsonrpcserver import Result, Success, dispatch - - def ping() -> Result: return Success("pong") @@ -85,45 +171,26 @@ response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', METHODS) ## The built-in server is for development `serve()` is a small server built on Python's `http.server`. It has no TLS, -no authentication and no request size limit. Use it to try things out, and put -`dispatch` behind a real web server or framework in production. The -[examples](examples.md) show how. +no authentication and no request size limit, and it doesn't set +`max_batch_size`. Use it to try things out, and put `dispatch` behind a real +web server or framework in production. The [examples](examples.md) show how. -## NaN and Infinity +By default, `serve()` listens on every network interface, so anyone who can +reach your machine can call your methods. Pass `"localhost"` to accept only +local connections, as in `serve("localhost", 8000)`. From 5.0.10 it says where +it's listening when it starts, including a note when that's every interface. -Python's `json` module accepts `NaN`, `Infinity` and numbers like `1e400` in a -request, which aren't valid JSON. To reject them, pass a stricter -`deserializer`: - -```python -import json -import math +## Strict JSON - -def reject(constant: str) -> None: - raise ValueError(f"{constant} is not valid JSON") - - -def finite_float(text: str) -> float: - number = float(text) - if math.isinf(number): - raise ValueError(f"{text} is too big") - return number - - -def strict_loads(request: str): - return json.loads(request, parse_constant=reject, parse_float=finite_float) -``` - -```python ->>> dispatch('{"jsonrpc": "2.0", "method": "ping", "params": [NaN], "id": 1}', METHODS, deserializer=strict_loads) -'{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error", "data": "NaN is not valid JSON"}, "id": null}' -``` - -Responses never contain them. The default serializer refuses to write them. +Python's `json` module accepts `NaN`, `Infinity` and numbers like `1e400` in a +request, which aren't valid JSON. [Validation](validation.md#nan-infinity-and-huge-numbers) +shows a stricter `deserializer` that rejects them. From 5.0.10, `dispatch` +and `async_dispatch` never write them with the default serializer: they send +an Internal error instead. `dispatch_to_serializable` gives you the float as +it is, so if your framework serializes the dict, check how it treats them. ## Reporting a vulnerability Please report security problems privately, through the [Security tab](https://github.com/bensynapse/jsonrpcserver/security) on GitHub. -See [SECURITY.md](https://github.com/bensynapse/jsonrpcserver/blob/main/SECURITY.md). +See the [security policy](security-policy.md). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..f23b67d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,119 @@ +--- +description: How to unit test jsonrpcserver methods. Call them directly, test them through dispatch with your own methods dict, and keep the global methods from leaking between tests. +--- + +# Testing + +## Call the method directly + +`@method` returns your function unchanged, so a test can call it like any +other function and compare the result: + +```python +from jsonrpcserver import Error, Result, Success, method + + +@method +def divide(a: float, b: float) -> Result: + if b == 0: + return Error(1, "Can't divide by zero") + return Success(a / b) + + +def test_divide() -> None: + assert divide(6, 3) == Success(2) + assert divide(1, 0) == Error(1, "Can't divide by zero") + + +test_divide() +``` + +`Success` and `Error` results compare equal when they hold the same values. + +## Test through dispatch + +To test what the client sees, including the JSON-RPC parts and the parameter +check, call `dispatch_to_serializable` with a dict of methods. It gives a dict, +which is easier to compare than a string: + +```python +from jsonrpcserver import dispatch_to_serializable + + +def test_divide_request() -> None: + response = dispatch_to_serializable( + '{"jsonrpc": "2.0", "method": "divide", "params": [1, 0], "id": 1}', + {"divide": divide}, + ) + assert response == { + "jsonrpc": "2.0", + "error": {"code": 1, "message": "Can't divide by zero"}, + "id": 1, + } + + +def test_divide_wrong_params() -> None: + response = dispatch_to_serializable( + '{"jsonrpc": "2.0", "method": "divide", "params": [1], "id": 1}', + {"divide": divide}, + ) + assert response is not None + assert response["error"]["code"] == -32602 + + +test_divide_request() +test_divide_wrong_params() +``` + +Passing the dict keeps the test to the methods you meant. Without it, +`dispatch` uses every method registered with `@method` anywhere in the test +process. + +## The global methods + +`@method` adds to one dict for the whole process, +`jsonrpcserver.methods.global_methods`. In a test suite, every test module +that's imported adds to it. A later `@method` with the same name replaces an +earlier one without a warning. If two test modules define a method called +`ping`, whichever is imported last wins. + +Either pass `methods` explicitly in tests, as above, or give test methods +unique names. To start a test with no registered methods, clear the dict and +put it back afterwards. With pytest: + +```python +from typing import Any, Dict, Iterator + +from jsonrpcserver.methods import global_methods + + +def clean_methods() -> Iterator[Dict[str, Any]]: # Decorate with @pytest.fixture. + saved = dict(global_methods) + global_methods.clear() + yield global_methods + global_methods.clear() + global_methods.update(saved) +``` + +## Async methods + +An async method is a coroutine function, so run it with `asyncio.run` or +pytest-asyncio's `@pytest.mark.asyncio`: + +```python +import asyncio + + +async def ping() -> Result: + return Success("pong") + + +assert asyncio.run(ping()) == Success("pong") +``` + +## Logged errors + +An exception that a method doesn't catch is logged, and the client gets a +bare Internal error. To check that in a test, use pytest's `caplog` fixture +and look for a record from the `jsonrpcserver.dispatcher` logger. See +[Errors and logging](errors.md#logging). diff --git a/docs/threads.md b/docs/threads.md new file mode 100644 index 0000000..e010a30 --- /dev/null +++ b/docs/threads.md @@ -0,0 +1,63 @@ +--- +description: jsonrpcserver's dispatch functions are safe to call from several threads, including on free-threaded Python 3.14t. Register methods at import time. +--- + +# Threads + +`dispatch` and the other dispatch functions can be called from several +threads at once, so they work with threaded servers such as Flask's, Django's +and `serve()`. That includes free-threaded Python (3.14t), where there is no +GIL. The test suite runs on 3.14t. It calls `dispatch` from many threads at +once, with single requests, batches and a different context in each thread, +and checks that every response is right. + +```python +from concurrent.futures import ThreadPoolExecutor + +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def square(number: int) -> Result: + return Success(number * number) + + +def call(number: int) -> str: + return dispatch( + f'{{"jsonrpc": "2.0", "method": "square", "params": [{number}], "id": {number}}}' + ) + + +with ThreadPoolExecutor(max_workers=8) as pool: + responses = list(pool.map(call, range(100))) + +print(responses[9]) +``` + +```text title="Output" +{"jsonrpc": "2.0", "result": 81, "id": 9} +``` + +`dispatch` keeps no state of its own between calls. Each call only reads the +methods dict. + +## Register methods at import time + +`@method` writes to one dict for the whole process. Run all your `@method` +decorators when your modules are imported, before the server starts handling +requests, which is what happens when they decorate top-level functions. Don't +add or replace methods while other threads are dispatching. If the set of +methods has to change at run time, pass a `methods` dict. To change it, build +a new dict and pass that from then on. + +## Your methods + +jsonrpcserver doesn't make your methods thread-safe. If a method changes +shared state, protect it with a lock as you would anywhere else. Objects +passed as `context` are shared too, if you pass the same one to every call. + +## asyncio + +`async_dispatch` runs on one event loop, and the requests in a batch run +concurrently on it. See [Async](async.md). Don't share one event loop's +objects between threads. diff --git a/docs/typing.md b/docs/typing.md new file mode 100644 index 0000000..f99d443 --- /dev/null +++ b/docs/typing.md @@ -0,0 +1,76 @@ +--- +description: Type checking jsonrpcserver code with mypy and pyright. What Result is, why mypy needs one setting for oslash, and how @method keeps your function's signature. +--- + +# Typing + +jsonrpcserver ships type hints (it has a `py.typed` marker), and its own code +is checked with mypy and pyright in strict mode. + +## Methods keep their signature + +`@method` returns your function unchanged, so type checkers see its real +signature. Calls to your methods from your own code are checked as usual: + +```python +from jsonrpcserver import Result, Success, method + + +@method +def add(a: int, b: int) -> Result: + return Success(a + b) + + +add(1, 2) # fine +# add("1", 2) is a type error: "str" is not "int". +``` + +!!! info "New in 5.0.10" + Before 5.0.10, type checkers saw every function decorated with `@method` + as `(*Any, **Any) -> Any`. They couldn't check calls to it. + +The values in a request aren't checked against these hints at run time. +jsonrpcserver only checks that the arguments fit the signature, so `add` +can still receive a string from a client. Check values in the method if it +matters. + +## What Result is + +`Result` is the return type of a method. `Success`, `Error` and +`InvalidParams` all return one. It comes from the +[oslash](https://pypi.org/project/oslash/) library: it's an `Either`, a +`Right` holding a `SuccessResult` or a `Left` holding an `ErrorResult`. You +don't need to look inside it. Return it, and annotate your methods with it. + +```pycon +>>> from jsonrpcserver import Error, Success +>>> from oslash.either import Left, Right +>>> isinstance(Success("pong"), Right) +True +>>> isinstance(Error(1, "Failed"), Left) +True +``` + +The [Dispatch](dispatch.md#dispatch_to_response) page shows how to read the +`Response` objects that `dispatch_to_response` gives, which work the same +way. The [roadmap](roadmap.md) plans to replace oslash in 6.0. + +## mypy needs one setting + +oslash has type hints but no `py.typed` marker. pyright reads them anyway. +mypy treats `Result` as `Any` unless you add this to `pyproject.toml`: + +```toml +[[tool.mypy.overrides]] +module = ["oslash", "oslash.*"] +follow_untyped_imports = true +``` + +Without it, mypy can't tell a method that returns `Success(...)` from one that +returns a plain value, which would give an Internal error at run time. + +## Async methods + +`async def` methods that return `Result` type-check too, and both `dispatch` +and `async_dispatch` accept them in `methods`. Only `async_dispatch` can +call them. See [Async](async.md). diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..cd56e6a --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,149 @@ +--- +description: How jsonrpcserver validates JSON-RPC requests. What the default schema checks, custom validators, what you lose by turning it off, and rejecting NaN. +--- + +# Validation + +After parsing a request, jsonrpcserver checks it against the JSON-RPC spec. +A request that fails gets a -32600 "Invalid request" response, and no method +runs. + +## What the default checks + +The default validator checks each request against a JSON schema. A request +must be an object with: + +- `"jsonrpc": "2.0"`, exactly +- a `method` that's a string +- `params`, if present, that's an array or an object +- an `id`, if present, that's a string, a number or null + +Nothing else is allowed in the object. + +```python +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") +``` + +```pycon +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "params": "x", "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The request failed schema validation"}, "id": null}' +``` + +The error doesn't say which rule the request broke, and its `id` is null +because the request couldn't be trusted. + +## A custom validator + +Pass `validator` to use your own. It gets the parsed request, usually a +dict, and should raise an exception of any kind if the request is invalid. +It can also get anything else that parsed, such as a number, a string or an +empty list, so don't assume a dict. What it +returns is ignored. In a batch, it's called once for each request. + +This one runs the default checks, then refuses requests without an `id`, so +clients can't send notifications: + +```python +from typing import Any, Dict + +from jsonrpcserver.main import default_validator + + +def no_notifications(request: Dict[str, Any]) -> None: + default_validator(request) + if "id" not in request: + raise ValueError("Notifications aren't allowed") +``` + +```pycon +>>> dispatch('{"jsonrpc": "2.0", "method": "ping"}', validator=no_notifications) +'{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The request failed schema validation"}, "id": null}' +``` + +The exception's message isn't sent to the client. + +!!! info "Changed in 5.0.10" + Before 5.0.10, a batch was validated as a whole: the validator was called + once, with the list. A validator that enforced a rule about the whole + batch, such as refusing batches, no longer sees the list. Use + `max_batch_size` for a size limit. + +## Turning it off + +Validation takes most of the time `dispatch` spends on a small method. If +your own code makes the requests, so you know they're valid, you can turn it +off: + +```pycon +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', validator=lambda _: None) +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +``` + +Without it, `dispatch` still never raises, but bad requests get odd answers: + +```pycon +>>> import logging +>>> logging.disable(logging.CRITICAL) # Keep the logged traceback out of this page. +>>> no_validation = lambda _: None +>>> dispatch('{"method": "ping", "id": 1}', validator=no_validation) +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "ping", "params": "x", "id": 1}', +... validator=no_validation, +... ) +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +>>> dispatch('{"jsonrpc": "2.0", "id": 1}', validator=no_validation) +'{"jsonrpc": "2.0", "error": {"code": -32000, "message": "Server error"}, "id": null}' +>>> logging.disable(logging.NOTSET) +``` + +A request with no `jsonrpc` member runs, and params that aren't a list or an +object are ignored. A request with no `method` gets a -32000 "Server error", +and jsonrpcserver logs it as an error of its own. An empty batch, `[]`, gets +no response at all instead of an error. Keep validation on for anything that +strangers can reach. + +## NaN, Infinity and huge numbers + +Python's `json` module accepts `NaN`, `Infinity` and numbers like `1e400` in +a request. They aren't valid JSON, and the schema can't see them, because +they're already floats by the time it runs. To reject them, pass a stricter +`deserializer`: + +```python +import json +import math + + +def reject(constant: str) -> None: + raise ValueError(f"{constant} is not valid JSON") + + +def finite_float(text: str) -> float: + number = float(text) + if math.isinf(number): + raise ValueError(f"{text} is too big") + return number + + +def strict_loads(request: str) -> Any: + return json.loads(request, parse_constant=reject, parse_float=finite_float) +``` + +```pycon +>>> dispatch( +... '{"jsonrpc": "2.0", "method": "ping", "params": [NaN], "id": 1}', +... deserializer=strict_loads, +... ) +'{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error", "data": "NaN is not valid JSON"}, "id": null}' +``` + +In 5.0.10, `dispatch` and `async_dispatch` never write them with the default +serializer. It refuses, and the client gets an Internal error instead. 5.0.9 +writes them as they are. `dispatch_to_serializable` returns the float itself, +so if your framework serializes the dict, check how it treats them. diff --git a/mkdocs.yml b/mkdocs.yml index dd6c237..ea611ab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,51 +1,137 @@ site_name: jsonrpcserver -site_description: Process incoming JSON-RPC requests in Python +site_description: Process incoming JSON-RPC 2.0 requests in Python site_url: https://bensynapse.github.io/jsonrpcserver/ repo_url: https://github.com/bensynapse/jsonrpcserver repo_name: bensynapse/jsonrpcserver edit_uri: edit/main/docs/ +copyright: >- + jsonrpcserver is MIT licensed. Created by Beau Barker. Maintained by + Synapse Research. theme: name: material + custom_dir: overrides + font: false logo: assets/logo.png + favicon: assets/favicon.png + icon: + repo: fontawesome/brands/github features: - - navigation.footer + - content.action.edit + - content.code.annotate - content.code.copy + - navigation.footer + - navigation.sections + - navigation.top + - search.highlight + - search.suggest + - toc.follow palette: - media: "(prefers-color-scheme)" scheme: default - primary: "pink" + primary: pink + accent: pink toggle: icon: material/brightness-auto name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default - primary: "pink" + primary: pink + accent: pink toggle: icon: material/brightness-7 name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate - primary: "pink" + primary: pink + accent: pink toggle: icon: material/brightness-4 name: Switch to system preference +extra: + # The version these docs describe while it isn't on PyPI yet. It shows a + # banner on every page. Remove it when that version is released (see + # RELEASING.md). + unreleased: "5.0.10" + pypi_version: "5.0.9" + social_image: assets/social-card.png plugins: - search + - mkdocstrings: + handlers: + python: + paths: [.] + options: + docstring_style: google + docstring_section_style: list + heading_level: 3 + members_order: source + merge_init_into_class: true + separate_signature: true + show_root_full_path: false + show_root_heading: true + show_signature_annotations: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true extra_css: - assets/extra.css +extra_javascript: + - assets/copy-without-prompts.js + - assets/a11y.js markdown_extensions: - admonition - - pymdownx.highlight + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.highlight: + # Adds language-pycon etc., which copy-without-prompts.js looks for. + pygments_lang_class: true - pymdownx.superfences - pymdownx.snippets: check_paths: true + - tables + - toc: + permalink: true +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn nav: - Home: index.md - - Methods: methods.md - - Dispatch: dispatch.md - - Async: async.md - - Examples: examples.md + - Guide: + - Methods: methods.md + - Dispatch: dispatch.md + - Notifications and batches: batches.md + - Async: async.md + - Errors and logging: errors.md + - Context: context.md + - Validation: validation.md + - Typing: typing.md + - Testing: testing.md + - Threads: threads.md + - Frameworks: + - Overview: examples.md + - http.server and serve(): frameworks/http-server.md + - Flask: frameworks/flask.md + - Werkzeug: frameworks/werkzeug.md + - Django: frameworks/django.md + - FastAPI: frameworks/fastapi.md + - aiohttp: frameworks/aiohttp.md + - Sanic: frameworks/sanic.md + - Tornado: frameworks/tornado.md + - websockets: frameworks/websockets.md + - ZeroMQ: frameworks/zeromq.md + - Socket.IO: frameworks/socketio.md - Security: security.md + - API reference: reference.md + - Migration: migration.md - FAQ: faq.md - Roadmap: roadmap.md - - Changelog: https://github.com/bensynapse/jsonrpcserver/blob/main/CHANGELOG.md + - Project: + - Changelog: changelog.md + - Contributing: contributing.md + - Security policy: security-policy.md + - License: license.md + - jsonrpcclient: https://bensynapse.github.io/jsonrpcclient/ diff --git a/overrides/404.html b/overrides/404.html new file mode 100644 index 0000000..4dfa56c --- /dev/null +++ b/overrides/404.html @@ -0,0 +1,22 @@ +{% extends "main.html" %} + +{% block content %} +

Page not found

+

+ There's no page at this address. It may have moved when the documentation + was reorganized. +

+ +

+ You can also search with the box at the top of the page, or + open an issue + if a link brought you here. +

+{% endblock %} diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..a004c8a --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block announce %} + {%- if config.extra.unreleased -%} + These docs describe jsonrpcserver {{ config.extra.unreleased }}, which + isn't on PyPI yet. pip install jsonrpcserver gives you + {{ config.extra.pypi_version }}, which sends exception messages to clients. + Security + has a fix for that, and the + changelog lists the other + differences. + {%- endif -%} +{% endblock %} + +{% block extrahead %} + {#- GitHub Pages can't enforce HTTPS for this site yet, so send plain-HTTP + visitors to the HTTPS address. -#} + + {%- set meta = page.meta if page and page.meta else {} -%} + {%- if meta.title -%} + {%- set title = meta.title -%} + {%- elif page and page.title and not page.is_homepage -%} + {%- set title = page.title ~ " - " ~ config.site_name -%} + {%- else -%} + {%- set title = config.site_name -%} + {%- endif -%} + {%- set description = meta.description or config.site_description -%} + {%- set image = config.site_url ~ config.extra.social_image -%} + + + + + + {%- if page and page.canonical_url %} + + {%- endif %} + + + + + + + + +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 8b56e3a..e79e830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi" [project] name = "jsonrpcserver" dynamic = ["version"] -description = "Process incoming JSON-RPC requests in Python" +description = "Process incoming JSON-RPC 2.0 requests in Python" readme = "README.md" license = "MIT" license-files = ["LICENSE"] @@ -14,7 +14,7 @@ authors = [ {name = "Beau Barker", email = "beau@explodinglabs.com"}, ] maintainers = [ - {name = "bensynapse"}, + {name = "Synapse Research"}, ] keywords = ["json-rpc", "jsonrpc", "json-rpc-2.0", "rpc", "server"] classifiers = [ @@ -48,7 +48,7 @@ Homepage = "https://bensynapse.github.io/jsonrpcserver/" Documentation = "https://bensynapse.github.io/jsonrpcserver/" Repository = "https://github.com/bensynapse/jsonrpcserver" Issues = "https://github.com/bensynapse/jsonrpcserver/issues" -Changelog = "https://github.com/bensynapse/jsonrpcserver/blob/main/CHANGELOG.md" +Changelog = "https://bensynapse.github.io/jsonrpcserver/changelog/" [project.optional-dependencies] test = [ @@ -61,8 +61,12 @@ test = [ [tool.flit.sdist] include = [ "CHANGELOG.md", + "CONTRIBUTING.md", + "RELEASING.md", + "SECURITY.md", "docs/", "mkdocs.yml", + "overrides/", "requirements/", "tests/", "tox.ini", @@ -71,9 +75,10 @@ include = [ [tool.ruff] target-version = "py38" + [tool.ruff.format] -# Its code blocks are "--8<--" include lines, not Python. -exclude = ["docs/examples.md"] +# Their code blocks include files with "--8<--" lines, which aren't Python. +exclude = ["docs/index.md", "docs/frameworks/*.md"] [tool.ruff.lint] select = ["E", "W", "F", "I", "UP", "B", "C4", "SIM", "RUF"] diff --git a/requirements/docs.txt b/requirements/docs.txt index 0c525b8..c9a765c 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,2 +1,4 @@ mkdocs==1.6.1 mkdocs-material==9.7.7 +mkdocstrings==1.0.6 +mkdocstrings-python==2.0.9 diff --git a/requirements/examples.txt b/requirements/examples.txt index 6314829..43a5b8b 100644 --- a/requirements/examples.txt +++ b/requirements/examples.txt @@ -4,8 +4,15 @@ django==6.1.1 fastapi==0.142.2 flask==3.1.3 flask-socketio==5.6.1 +# FastAPI's test client, used on the Context page. +httpx2==2.13.1 +# The quickstart calls the server with jsonrpcclient and requests. +jsonrpcclient==4.0.3 +# The FAQ's orjson example. +orjson==3.12.0 python-socketio[client]==5.17.0 pyzmq==27.2.0 +requests==2.34.2 sanic==25.12.1 tornado==6.5.10 ujson==6.0.0 diff --git a/requirements/site.txt b/requirements/site.txt new file mode 100644 index 0000000..78e0857 --- /dev/null +++ b/requirements/site.txt @@ -0,0 +1,2 @@ +# For .github/scripts/check_site.py, which CI's site job runs. +playwright==1.63.0 diff --git a/tests/doc_examples.py b/tests/doc_examples.py index e8aa78d..d659da2 100644 --- a/tests/doc_examples.py +++ b/tests/doc_examples.py @@ -6,31 +6,77 @@ request ids count up from 1 as they do for a reader trying the examples. Run each file in a fresh interpreter (tests/test_docs.py does). -A python block that contains ">>>" is a doctest: the output must match, and -"..." matches anything. Any other python block just has to run. +A python or pycon block that contains ">>>" is a doctest: the output must +match, and "..." matches anything. Any other python block has to run, and if +the next block is a text block titled "Output" (```text title="Output"), what +the code prints must match it. -An HTML comment on the line before a block can skip it: +HTML comments on the lines just before a block change how it runs: skip unless ujson can be imported skip on older Pythons -Blocks that include a file with "--8<--" are skipped. Those are the transport -examples, which docs/examples/check_examples.py runs against a test server. + run the quickstart server + (docs/examples/quickstart.py) on + localhost:8000 while the block runs + never run it, such as code for 4.x +A block can have several of these, one per line. Fences can be indented, as +they are inside an admonition. Blocks that include a file with "--8<--" are +skipped. Those are the framework examples, which +docs/examples/check_examples.py starts and sends requests to. """ import doctest import importlib.util +import io import re +import socket +import subprocess import sys +import time +from contextlib import contextmanager, nullcontext, redirect_stdout from pathlib import Path -from typing import Any, Dict, List, NamedTuple - -FENCE = re.compile(r"^```\s*(\w*)\s*$") -MARKER = re.compile(r"^$") +from typing import ( + Any, + ContextManager, + Dict, + Generator, + List, + NamedTuple, + Optional, + Tuple, +) + +QUICKSTART = Path(__file__).parent.parent / "docs" / "examples" / "quickstart.py" +PORT = 8000 + +# A fence can be indented, for example inside an admonition. +FENCE = re.compile(r"^(\s*)```\s*(\w*)(.*)$") +OUTPUT = re.compile(r'^\s*title="Output"\s*$') +MARKER = re.compile(r"^$") class Block(NamedTuple): line: int code: str - marker: str + markers: List[str] + output: Optional[str] + + +def fenced(lines: List[str], i: int) -> Tuple[str, str, List[str], int]: + """Read the fenced block starting at line i. + + Return its language, the rest of the opening line, its body without the + fence's indentation, and the index of the line after the closing fence. + """ + match = FENCE.match(lines[i]) + assert match + indent, language, rest = match.groups() + body: List[str] = [] + i += 1 + while i < len(lines) and not FENCE.match(lines[i]): + line = lines[i] + body.append(line[len(indent) :] if line.startswith(indent) else line) + i += 1 + return language, rest, body, i + 1 def python_blocks(text: str) -> List[Block]: @@ -38,34 +84,86 @@ def python_blocks(text: str) -> List[Block]: lines = text.splitlines() i = 0 while i < len(lines): - match = FENCE.match(lines[i]) - if not match: + if not FENCE.match(lines[i]): i += 1 continue - language, start = match.group(1), i - i += 1 - body: List[str] = [] - while i < len(lines) and not FENCE.match(lines[i]): - body.append(lines[i]) - i += 1 - i += 1 + start = i + language, _, body, i = fenced(lines, i) if language in ("python", "py", "pycon"): - marker = lines[start - 1].strip() if start > 0 else "" - blocks.append(Block(start + 1, "\n".join(body) + "\n", marker)) + markers: List[str] = [] + j = start - 1 + while j >= 0 and MARKER.match(lines[j].strip()): + markers.append(lines[j].strip()) + j -= 1 + code = "\n".join(body) + "\n" + blocks.append(Block(start + 1, code, markers, output_after(lines, i))) return blocks +def output_after(lines: List[str], i: int) -> Optional[str]: + """Return the text of an "Output" block that starts at or after line i.""" + while i < len(lines) and not lines[i].strip(): + i += 1 + if i >= len(lines) or not FENCE.match(lines[i]): + return None + language, rest, body, _ = fenced(lines, i) + if language != "text" or not OUTPUT.match(rest): + return None + return "\n".join(body) + + def skip_reason(block: Block) -> str: if "--8<--" in block.code: return "included file" - match = MARKER.match(block.marker) - if not match: - return "" - kind, value = match.groups() - if kind == "requires": - return "" if importlib.util.find_spec(value) else f"needs {value}" - wanted = tuple(int(part) for part in value.split(".")) - return f"needs Python {value}" if sys.version_info < wanted else "" + for marker in block.markers: + match = MARKER.match(marker) + assert match + kind, value = match.groups() + if kind == "skip": + return value or "marked skip" + if kind == "requires" and not importlib.util.find_spec(value): + return f"needs {value}" + if kind == "min-python": + wanted = tuple(int(part) for part in value.split(".")) + if sys.version_info < wanted: + return f"needs Python {value}" + return "" + + +def port_open() -> bool: + try: + with socket.create_connection(("localhost", PORT), timeout=1): + return True + except OSError: + return False + + +@contextmanager +def quickstart_server() -> Generator[None, None, None]: + """Run the quickstart server on localhost:8000, as a reader would.""" + if port_open(): + raise RuntimeError(f"Port {PORT} is already in use") + server = subprocess.Popen( + [sys.executable, str(QUICKSTART)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.monotonic() + 30 + while not port_open(): + if server.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("The quickstart server didn't start") + time.sleep(0.1) + yield + finally: + server.terminate() + server.wait(10) + + +def server_for(block: Block) -> ContextManager[None]: + if "" in block.markers: + return quickstart_server() + return nullcontext() def run_file(path: Path) -> int: @@ -82,23 +180,42 @@ def run_file(path: Path) -> int: skipped += 1 continue ran += 1 - if ">>>" in block.code: - test = parser.get_doctest( - block.code, namespace, str(path), str(path), block.line - ) - failures += runner.run(test, clear_globs=False).failed - # DocTest works on a copy of the namespace. Keep what it defined. - namespace.update(test.globs) - else: - try: - exec(compile(block.code, f"{path}:{block.line}", "exec"), namespace) - except Exception as exc: - print(f"{path}:{block.line}: {type(exc).__name__}: {exc}") - failures += 1 + with server_for(block): + failures += run_block(path, block, namespace, runner, parser) print(f"{path}: {ran} blocks run, {skipped} skipped, {failures} failed") return failures +def run_block( + path: Path, + block: Block, + namespace: Dict[str, Any], + runner: doctest.DocTestRunner, + parser: doctest.DocTestParser, +) -> int: + """Run one block and return the number of failures.""" + if ">>>" in block.code: + test = parser.get_doctest( + block.code, namespace, str(path), str(path), block.line + ) + failed = runner.run(test, clear_globs=False).failed + # DocTest works on a copy of the namespace. Keep what it defined. + namespace.update(test.globs) + return failed + printed = io.StringIO() + try: + with redirect_stdout(printed): + exec(compile(block.code, f"{path}:{block.line}", "exec"), namespace) + except Exception as exc: + print(f"{path}:{block.line}: {type(exc).__name__}: {exc}") + return 1 + if block.output is not None and printed.getvalue().strip() != block.output.strip(): + print(f"{path}:{block.line}: printed {printed.getvalue()!r}") + print(f"{' ' * len(str(path))} expected {block.output!r}") + return 1 + return 0 + + if __name__ == "__main__": total = sum(run_file(Path(arg)) for arg in sys.argv[1:]) sys.exit(1 if total else 0) diff --git a/tests/test_docs.py b/tests/test_docs.py index b6abe50..ca2fe32 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,16 +1,33 @@ -"""Every Python example in the README and docs must run and show true output.""" +"""Every Python example in the README and docs must run and show true output. +The framework examples run in CI's docs job, which has the frameworks +installed. The checks here keep the docs complete and in step with the code. +""" + +import re +import runpy import subprocess import sys from pathlib import Path +from typing import Any, Dict, List, Set import pytest +import jsonrpcserver + ROOT = Path(__file__).parent.parent -FILES = [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))] +DOCS = ROOT / "docs" +FILES = [ROOT / "README.md", *sorted(DOCS.rglob("*.md"))] +# The constants that docs/examples/check_examples.py tests against. +CHECKER = runpy.run_path(str(DOCS / "examples" / "check_examples.py")) +CURL: str = CHECKER["CURL"] +CURL_OUTPUT: str = CHECKER["CURL_OUTPUT"] +EXAMPLES: Dict[str, Any] = CHECKER["EXAMPLES"] +NOT_SERVERS: Set[str] = CHECKER["NOT_SERVERS"] -@pytest.mark.parametrize("path", FILES, ids=lambda path: path.name) + +@pytest.mark.parametrize("path", FILES, ids=lambda path: str(path.relative_to(ROOT))) def test_doc_examples(path: Path) -> None: # A fresh interpreter per file, so methods registered with @method on one # page don't leak into another. @@ -20,3 +37,101 @@ def test_doc_examples(path: Path) -> None: text=True, ) assert result.returncode == 0, result.stdout + result.stderr + + +def block_after(text: str, after: str, language: str) -> str: + fence = f"```{language}\n" + start = text.index(fence, text.index(after)) + len(fence) + return text[start : text.index("```", start)] + + +def test_readme_quickstart_is_the_tested_example() -> None: + """check_examples.py runs quickstart.py, so the README must match it.""" + readme = (ROOT / "README.md").read_text() + example = (DOCS / "examples" / "quickstart.py").read_text() + assert block_after(readme, "## Quickstart", "python") == example + assert block_after(readme, "## Quickstart", "text") == CURL_OUTPUT + "\n" + + +@pytest.mark.parametrize("path", FILES, ids=lambda path: str(path.relative_to(ROOT))) +def test_every_curl_command_is_the_tested_one(path: Path) -> None: + """check_examples.py runs CURL against the quickstart server.""" + for line in path.read_text().splitlines(): + if line.startswith("curl "): + assert line == CURL + + +def test_every_included_example_is_checked() -> None: + included: Set[str] = set() + for path in DOCS.rglob("*.md"): + included.update( + re.findall(r'--8<-- "docs/examples/(\w+\.py)"', path.read_text()) + ) + assert included + assert included <= set(EXAMPLES) | NOT_SERVERS + # Every example server is shown somewhere in the docs. + assert set(EXAMPLES) <= included + + +def test_examples_use_port_8000_on_localhost() -> None: + for path in (DOCS / "examples").glob("*.py"): + text = path.read_text() + assert "5000" not in text, path.name + assert "tcp://*" not in text, path.name + + +@pytest.mark.parametrize("name", sorted(EXAMPLES)) +def test_examples_set_max_batch_size(name: str) -> None: + """The Security page says to set it on every dispatch call.""" + text = (DOCS / "examples" / name).read_text() + calls = re.findall(r"dispatch\((.*)\)", text) + if name == "quickstart.py": + # serve() calls dispatch itself. + assert calls == [] + else: + assert calls + assert all("max_batch_size=100" in call for call in calls), calls + + +def public_names() -> List[str]: + return [*jsonrpcserver.__all__, "__version__"] + + +@pytest.mark.parametrize("name", public_names()) +def test_reference_documents_every_public_name(name: str) -> None: + reference = (DOCS / "reference.md").read_text() + assert f"::: jsonrpcserver.{name}\n" in reference + + +def test_python_versions_badge_matches_classifiers() -> None: + pyproject = (ROOT / "pyproject.toml").read_text() + versions = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', pyproject) + readme = (ROOT / "README.md").read_text() + badge = re.search(r"img\.shields\.io/badge/python-([^-]+)-blue", readme) + assert badge + assert badge.group(1).split("%20%7C%20") == versions + + +def test_tagline_is_the_same_everywhere() -> None: + tagline = "Process incoming JSON-RPC 2.0 requests in Python" + assert f'description = "{tagline}"' in (ROOT / "pyproject.toml").read_text() + assert f"site_description: {tagline}\n" in (ROOT / "mkdocs.yml").read_text() + assert f"{tagline}" in (ROOT / "README.md").read_text() + assert f"_{tagline}._" in (DOCS / "index.md").read_text() + + +def test_every_page_has_a_description() -> None: + for path in DOCS.rglob("*.md"): + text = path.read_text() + assert text.startswith("---\n"), path + front_matter = text.split("---\n")[1] + assert re.search(r"^description: .{50,}$", front_matter, re.M), path + + +def test_old_domains_are_named_not_linked() -> None: + """The FAQ names the old domains so readers recognise them. CI's domain + check skips that file, so make sure they're never links.""" + faq = (DOCS / "faq.md").read_text() + for match in re.finditer(r"jsonrpc(?:client|server)\.com", faq): + before = faq[max(0, match.start() - 12) : match.start()] + assert "://" not in before and "](" not in before and "www." not in before diff --git a/tests/test_threading.py b/tests/test_threading.py new file mode 100644 index 0000000..b29e9da --- /dev/null +++ b/tests/test_threading.py @@ -0,0 +1,92 @@ +"""dispatch must give the right response to each request when many threads +call it at once. CI runs this on free-threaded 3.14t too.""" + +import json +import sys +import threading +from typing import Any, Callable, Iterator, List + +import pytest + +from jsonrpcserver import Error, Result, Success, dispatch, method + +THREADS = 8 +CALLS = 500 + + +@pytest.fixture(autouse=True) +def frequent_thread_switches() -> Iterator[None]: + """On GIL builds, switch threads as often as possible to provoke races.""" + old = sys.getswitchinterval() + sys.setswitchinterval(1e-6) + yield + sys.setswitchinterval(old) + + +@method(name="threading_echo") +def echo(value: Any) -> Result: + return Success(value) + + +def double(context: int, value: int) -> Result: + if value < 0: + return Error(1, "Negative", value) + return Success(context * value) + + +def hammer(work: Callable[[int, int], None]) -> List[str]: + """Run work(thread, call) from several threads at once and collect errors.""" + errors: List[str] = [] + lock = threading.Lock() + barrier = threading.Barrier(THREADS) + + def worker(thread: int) -> None: + barrier.wait() + for call in range(CALLS): + try: + work(thread, call) + except Exception as exc: + with lock: + errors.append(repr(exc)) + + threads = [threading.Thread(target=worker, args=(n,)) for n in range(THREADS)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + return errors + + +def test_global_methods_from_many_threads() -> None: + def work(thread: int, call: int) -> None: + id_ = thread * CALLS + call + request = {"jsonrpc": "2.0", "method": "threading_echo", "params": [id_]} + response = json.loads(dispatch(json.dumps({**request, "id": id_}))) + assert response == {"jsonrpc": "2.0", "result": id_, "id": id_} + + assert hammer(work) == [] + + +def test_batches_and_context_from_many_threads() -> None: + def work(thread: int, call: int) -> None: + batch = [ + {"jsonrpc": "2.0", "method": "double", "params": [call], "id": 1}, + {"jsonrpc": "2.0", "method": "double", "params": [-1], "id": 2}, + {"jsonrpc": "2.0", "method": "double", "params": [call]}, + ] + response = dispatch( + json.dumps(batch), + {"double": double}, + context=thread, + max_batch_size=3, + ) + assert json.loads(response) == [ + {"jsonrpc": "2.0", "result": thread * call, "id": 1}, + { + "jsonrpc": "2.0", + "error": {"code": 1, "message": "Negative", "data": -1}, + "id": 2, + }, + ] + + assert hammer(work) == []