JSON-RPC Request Builder — Python source
Build valid JSON-RPC 2.0 requests, notifications, success responses, and error responses, plus batch arrays. Validate message structure.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""json-rpc-builder - polyglot showcase port (Python).
Pure JSON-RPC 2.0 message builder for the JSON-RPC Builder tool on CosmoDev
(dev.cosmolabs.org). Ported from the canonical TypeScript source at
src/lib/jsonRpc.ts so the tool page can display the same logic across six
languages.
Self-contained: standard library only (json + dataclasses). No external deps,
no numpy/pip packages.
Behavior is identical to the TS lib: same inputs -> same outputs. Builds
requests, notifications, success/error responses, and batches. Never raises;
every call returns an Outcome.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, List, Optional, Union
# --- Protocol constants ------------------------------------------------------
JSONRPC_VERSION = "2.0"
# Pre-defined messages for the error codes reserved by the JSON-RPC 2.0 spec
# (https://www.jsonrpc.org/specification#error_object, section 5.1). The
# -32000..-32099 band is reserved for server-defined "Server error" values.
STANDARD_ERRORS: dict[int, str] = {
-32700: "Parse error",
-32600: "Invalid Request",
-32601: "Method not found",
-32602: "Invalid params",
-32603: "Internal error",
-32000: "Server error",
}
# Sentinel that distinguishes "argument not supplied" from "argument is None".
# JSON-RPC lets params/data be any JSON value (including null), so we cannot
# overload `None` to mean "absent" the way many Python APIs do. Callers pass
# `_UNSET` (the default) only by not naming the argument.
_UNSET = object()
# A JSON-RPC id is a string, a number, or null (the spec allows null when the
# source id of an error cannot be determined).
RpcId = Union[str, int, None]
@dataclass
class Outcome:
"""Result of a build call: success flag, serialized JSON, error reason.
`json` is "" on failure; `error` is None on success. Mirrors the TS Outcome
one-for-one so the UI can consume both ports identically.
"""
ok: bool
json: str
error: Optional[str]
# --- Internal helpers --------------------------------------------------------
def _is_non_empty_string(value: Any) -> bool:
return isinstance(value, str) and len(value) > 0
def _stringify(obj: Any, indent: Optional[int]) -> str:
"""Serialize `obj` to JSON.
Note: Python's json.dumps treats `indent=0` differently from the JS
JSON.stringify - with indent=0 Python still inserts newlines (just with
zero extra spaces). To match the TS lib we treat any falsy/None indent as
"compact, single line" and a positive integer as "pretty with N spaces".
"""
if indent and indent > 0:
return json.dumps(obj, indent=indent)
# item_separator "," and key_separator ":" reproduce JSON.stringify's
# compact form (json.dumps adds a space after "," by default).
return json.dumps(obj, separators=(",", ":"))
# --- Public builders ---------------------------------------------------------
def build_request(
method: str,
params: Any = _UNSET,
id: RpcId = 1,
indent: Optional[int] = None,
) -> Outcome:
"""Build a JSON-RPC 2.0 Request.
A request carries an `id` that the server echoes back in its response,
making it a synchronous ask/reply pair (unlike a Notification).
"""
if not _is_non_empty_string(method):
return Outcome(False, "", "method must be a non-empty string")
obj: dict[str, Any] = {"jsonrpc": JSONRPC_VERSION, "method": method}
# Only emit `params` when it was actually supplied; a passed `None` is a
# legitimate JSON null and is preserved.
if params is not _UNSET:
obj["params"] = params
obj["id"] = id
return Outcome(True, _stringify(obj, indent), None)
def build_notification(
method: str,
params: Any = _UNSET,
indent: Optional[int] = None,
) -> Outcome:
"""Build a JSON-RPC 2.0 Notification (fire-and-forget: no `id`, no reply)."""
if not _is_non_empty_string(method):
return Outcome(False, "", "method must be a non-empty string")
obj: dict[str, Any] = {"jsonrpc": JSONRPC_VERSION, "method": method}
if params is not _UNSET:
obj["params"] = params
return Outcome(True, _stringify(obj, indent), None)
def build_success_response(
id: RpcId,
result: Any,
indent: Optional[int] = None,
) -> Outcome:
"""Build a JSON-RPC 2.0 success Response (echoes the request id)."""
obj = {"jsonrpc": JSONRPC_VERSION, "result": result, "id": id}
return Outcome(True, _stringify(obj, indent), None)
def build_error_response(
id: RpcId,
code: int,
message: Optional[str] = None,
data: Any = _UNSET,
indent: Optional[int] = None,
) -> Outcome:
"""Build a JSON-RPC 2.0 error Response.
The message falls back through a chain: explicit argument -> the standard
text for the given code -> the generic word "Error".
"""
msg = message if message is not None else STANDARD_ERRORS.get(code, "Error")
error: dict[str, Any] = {"code": code, "message": msg}
if data is not _UNSET:
error["data"] = data
obj = {"jsonrpc": JSONRPC_VERSION, "error": error, "id": id}
return Outcome(True, _stringify(obj, indent), None)
def build_batch(messages: Any, indent: Optional[int] = None) -> Outcome:
"""Serialize a batch of pre-built JSON-RPC messages.
Per spec section 6, a batch is a non-empty array of messages exchanged in
a single round-trip. The caller supplies already-built message objects
(not strings); we only wrap and serialize them.
"""
# bool is a subclass of int but never a list, and strings aren't lists -
# the isinstance check alone is sufficient and intentional.
if not isinstance(messages, list) or len(messages) == 0:
return Outcome(False, "", "batch must be a non-empty array")
return Outcome(True, _stringify(messages, indent), None)
@dataclass
class ValidationResult:
"""Outcome of `validate_rpc`: overall verdict plus a list of problems."""
valid: bool
errors: List[str]
def validate_rpc(obj: Any) -> ValidationResult:
"""Lightweight structural check for a JSON-RPC 2.0 message.
Permissive about field VALUES (it does not recurse into params/error
data); it only verifies the message "shape" so a UI can surface what is
wrong in plain English. Returns every problem found, not just the first.
A parsed JSON object is represented in Python as a dict, so we accept a
dict here. (In JS every `typeof === "object"` value passes the first gate;
the Python port narrows that to the natural dict representation.)
"""
if not isinstance(obj, dict):
return ValidationResult(False, ["Not an object."])
errors: List[str] = []
if obj.get("jsonrpc") != JSONRPC_VERSION:
errors.append('jsonrpc must be "2.0".')
if "method" in obj and not isinstance(obj["method"], str):
errors.append("method must be a string.")
if "result" in obj and "error" in obj:
errors.append("cannot have both result and error.")
# A well-formed message is exactly one of: request/notification (method),
# success response (result), or error response (error).
if not any(k in obj for k in ("method", "result", "error")):
errors.append("must have method, result, or error.")
return ValidationResult(len(errors) == 0, errors)
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →