Skip to content

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 →