Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -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.
194 changes: 194 additions & 0 deletions .github/scripts/check_site.py
Original file line number Diff line number Diff line change
@@ -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"<loc>(.*?)</loc>", 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:])))
33 changes: 30 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading