Skip to content

JSON-RPC Request Builder — Rust source

Build valid JSON-RPC 2.0 requests, notifications, success responses, and error responses, plus batch arrays. Validate message structure.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-rpc-builder - Rust port
// =============================================================================
// Pure JSON-RPC 2.0 message builder: requests, notifications, success/error
// responses, and batches.
//
// CosmoDev polyglot showcase port of the `json-rpc-builder` tool, ported
// from src/lib/jsonRpc.ts (the canonical, live TypeScript lib).
// Display source - part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
//
// Behavior is identical to the TS lib: same inputs -> same outputs. Pure data
// logic; every builder returns an Outcome (never panics).
//
// The Rust standard library ships no JSON type, so this file contains a small
// self-contained JSON value enum plus a serializer - no external crates
// required. Objects keep their insertion order (Vec of pairs) so the emitted
// field order matches the TS lib byte-for-byte (jsonrpc, method, params, id).

use std::fmt;

// Protocol version emitted on every message.
pub const JSONRPC_VERSION: &str = "2.0";

/// Pre-defined messages for the error codes the JSON-RPC 2.0 spec reserves
/// (https://www.jsonrpc.org/specification#error_object, section 5.1). The
/// -32000..-32099 band is reserved for server-defined "Server error" values.
pub fn standard_message(code: i64) -> Option<&'static str> {
    match code {
        -32700 => Some("Parse error"),
        -32600 => Some("Invalid Request"),
        -32601 => Some("Method not found"),
        -32602 => Some("Invalid params"),
        -32603 => Some("Internal error"),
        -32000 => Some("Server error"),
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// Json: a tiny ordered JSON value type (stdlib only).
// ---------------------------------------------------------------------------

/// A JSON value. `Object` is a `Vec<(String, Json)>` rather than a map so
/// insertion order - and therefore the on-wire field order - is preserved,
/// matching the TypeScript lib's output.
#[derive(Debug, Clone, PartialEq)]
pub enum Json {
    Null,
    Bool(bool),
    Int(i64),
    Float(f64),
    Str(String),
    Array(Vec<Json>),
    Object(Vec<(String, Json)>),
}

impl Json {
    // -- constructors --------------------------------------------------------

    pub fn null() -> Self {
        Json::Null
    }
    pub fn bool(b: bool) -> Self {
        Json::Bool(b)
    }
    pub fn int(i: i64) -> Self {
        Json::Int(i)
    }
    pub fn float(f: f64) -> Self {
        Json::Float(f)
    }
    pub fn str(s: impl Into<String>) -> Self {
        Json::Str(s.into())
    }
    pub fn array(items: Vec<Json>) -> Self {
        Json::Array(items)
    }
    pub fn object(fields: Vec<(&str, Json)>) -> Self {
        Json::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
    }

    // -- inspection (used by validate_rpc) -----------------------------------

    /// Look up a field on an Object; returns None for non-objects or missing
    /// keys. Mirrors how the TS lib reads keys off a parsed object.
    pub fn get(&self, key: &str) -> Option<&Json> {
        match self {
            Json::Object(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
            _ => None,
        }
    }

    pub fn has_key(&self, key: &str) -> bool {
        self.get(key).is_some()
    }

    // -- serialization -------------------------------------------------------

    /// Render to a JSON string. `indent == 0` produces compact, single-line
    /// output (matching JS `JSON.stringify(obj, null, 0)`); `indent > 0`
    /// produces pretty output indented by that many spaces per level
    /// (matching JS `JSON.stringify(obj, null, indent)`).
    pub fn to_json_string(&self, indent: usize) -> String {
        let mut out = String::new();
        self.render(&mut out, indent, 0);
        out
    }

    fn render(&self, out: &mut String, indent: usize, level: usize) {
        match self {
            Json::Null => out.push_str("null"),
            Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
            Json::Int(n) => out.push_str(&n.to_string()),
            // JS JSON.stringify turns NaN/Infinity into null; do the same so
            // the output is always valid JSON.
            Json::Float(f) => out.push_str(&format_float(*f)),
            Json::Str(s) => {
                out.push('"');
                escape_json_string(s, out);
                out.push('"');
            }
            Json::Array(items) => {
                if items.is_empty() {
                    out.push_str("[]");
                    return;
                }
                out.push('[');
                for (i, item) in items.iter().enumerate() {
                    if indent > 0 {
                        newline_pad(out, indent, level + 1);
                    }
                    item.render(out, indent, level + 1);
                    if i + 1 < items.len() {
                        out.push(',');
                    }
                }
                if indent > 0 {
                    newline_pad(out, indent, level);
                }
                out.push(']');
            }
            Json::Object(fields) => {
                if fields.is_empty() {
                    out.push_str("{}");
                    return;
                }
                out.push('{');
                for (i, (k, v)) in fields.iter().enumerate() {
                    if indent > 0 {
                        newline_pad(out, indent, level + 1);
                    }
                    out.push('"');
                    escape_json_string(k, out);
                    // Compact key separator is ":"; pretty adds one space, as
                    // JS JSON.stringify does.
                    out.push_str(if indent > 0 { "\": " } else { "\":" });
                    v.render(out, indent, level + 1);
                    if i + 1 < fields.len() {
                        out.push(',');
                    }
                }
                if indent > 0 {
                    newline_pad(out, indent, level);
                }
                out.push('}');
            }
        }
    }
}

/// Push a newline followed by `indent * level` spaces - the per-level
/// indentation unit used by the pretty renderer.
fn newline_pad(out: &mut String, indent: usize, level: usize) {
    out.push('\n');
    for _ in 0..(indent * level) {
        out.push(' ');
    }
}

/// Format an f64 the way JSON expects: NaN/Infinity become "null" (JS
/// coercion); everything else uses Rust's default short representation.
fn format_float(f: f64) -> String {
    if !f.is_finite() {
        return "null".to_string();
    }
    format!("{}", f)
}

/// Escape a string for safe inclusion between JSON quotes (RFC 8259 section 7):
/// the mandatory short escapes for the common control chars, and \u00XX for
/// any other control character below U+0020. Everything else (including
/// multibyte UTF-8) is emitted verbatim.
fn escape_json_string(s: &str, out: &mut String) {
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
}

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

/// Result of a build call: success flag, serialized JSON (empty on failure),
/// and an optional failure reason. Mirrors the TS Outcome one-for-one.
#[derive(Debug, Clone, PartialEq)]
pub struct Outcome {
    pub ok: bool,
    pub json: String,
    pub error: Option<String>,
}

impl Outcome {
    fn ok(json: String) -> Self {
        Outcome {
            ok: true,
            json,
            error: None,
        }
    }
    fn fail(reason: &str) -> Self {
        Outcome {
            ok: false,
            json: String::new(),
            error: Some(reason.to_string()),
        }
    }
}

/// 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 (as opposed to a notification).
///
/// `params` is `Option<&Json>`: `None` omits the field, `Some(Json::Null())`
/// emits `"params":null`. This makes Rust the most faithful of the six ports -
/// it can represent the explicit-null case the TS lib distinguishes from
/// `undefined`.
pub fn build_request(
    method: &str,
    params: Option<&Json>,
    id: &Json,
    indent: usize,
) -> Outcome {
    if method.is_empty() {
        return Outcome::fail("method must be a non-empty string");
    }
    let mut fields: Vec<(String, Json)> = Vec::with_capacity(4);
    fields.push(("jsonrpc".to_string(), Json::str(JSONRPC_VERSION)));
    fields.push(("method".to_string(), Json::str(method)));
    if let Some(p) = params {
        fields.push(("params".to_string(), p.clone()));
    }
    fields.push(("id".to_string(), id.clone()));
    Outcome::ok(Json::Object(fields).to_json_string(indent))
}

/// Build a JSON-RPC 2.0 Notification: fire-and-forget, no id, server MUST NOT
/// reply. Used for events/telemetry.
pub fn build_notification(method: &str, params: Option<&Json>, indent: usize) -> Outcome {
    if method.is_empty() {
        return Outcome::fail("method must be a non-empty string");
    }
    let mut fields: Vec<(String, Json)> = Vec::with_capacity(3);
    fields.push(("jsonrpc".to_string(), Json::str(JSONRPC_VERSION)));
    fields.push(("method".to_string(), Json::str(method)));
    if let Some(p) = params {
        fields.push(("params".to_string(), p.clone()));
    }
    Outcome::ok(Json::Object(fields).to_json_string(indent))
}

/// Build a JSON-RPC 2.0 success Response. The id echoes the request id this
/// answers; `result` is always emitted (even when it is Json::Null).
pub fn build_success_response(id: &Json, result: &Json, indent: usize) -> Outcome {
    let fields = vec![
        ("jsonrpc".to_string(), Json::str(JSONRPC_VERSION)),
        ("result".to_string(), result.clone()),
        ("id".to_string(), id.clone()),
    ];
    Outcome::ok(Json::Object(fields).to_json_string(indent))
}

/// Build a JSON-RPC 2.0 error Response.
///
/// The message falls back through a chain: explicit non-empty argument ->
/// the standard text for the given code -> the generic word "Error". `data`
/// carries optional structured detail.
pub fn build_error_response(
    id: &Json,
    code: i64,
    message: Option<&str>,
    data: Option<&Json>,
    indent: usize,
) -> Outcome {
    let msg = match message {
        Some(s) if !s.is_empty() => s,
        _ => standard_message(code).unwrap_or("Error"),
    };
    let mut error_fields = vec![
        ("code".to_string(), Json::Int(code)),
        ("message".to_string(), Json::str(msg)),
    ];
    if let Some(d) = data {
        error_fields.push(("data".to_string(), d.clone()));
    }
    let fields = vec![
        ("jsonrpc".to_string(), Json::str(JSONRPC_VERSION)),
        ("error".to_string(), Json::Object(error_fields)),
        ("id".to_string(), id.clone()),
    ];
    Outcome::ok(Json::Object(fields).to_json_string(indent))
}

/// 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 slice type guarantees "array"; only the non-empty
/// check remains (the TS lib's Array.isArray gate is enforced by the type).
pub fn build_batch(messages: &[Json], indent: usize) -> Outcome {
    if messages.is_empty() {
        return Outcome::fail("batch must be a non-empty array");
    }
    Outcome::ok(Json::Array(messages.to_vec()).to_json_string(indent))
}

/// Outcome of `validate_rpc`: overall verdict plus a list of human-readable
/// problems (empty when valid is true).
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationResult {
    pub valid: bool,
    pub errors: Vec<String>,
}

/// 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.
pub fn validate_rpc(obj: &Json) -> ValidationResult {
    // Only an Object is a candidate JSON-RPC message; any other JSON value
    // (number, array, string, null) is rejected up front.
    if !matches!(obj, Json::Object(_)) {
        return ValidationResult {
            valid: false,
            errors: vec!["Not an object.".to_string()],
        };
    }

    let mut errors: Vec<String> = Vec::new();

    // Absent jsonrpc, or any value other than the literal "2.0", is invalid.
    let jsonrpc_ok = matches!(
        obj.get("jsonrpc"),
        Some(Json::Str(s)) if s == JSONRPC_VERSION
    );
    if !jsonrpc_ok {
        errors.push(r#"jsonrpc must be "2.0"."#.to_string());
    }
    if let Some(method) = obj.get("method") {
        if !matches!(method, Json::Str(_)) {
            errors.push("method must be a string.".to_string());
        }
    }
    let has_result = obj.has_key("result");
    let has_error = obj.has_key("error");
    if has_result && has_error {
        errors.push("cannot have both result and error.".to_string());
    }
    // A well-formed message is exactly one of: request/notification (method),
    // success response (result), or error response (error).
    let has_method = obj.has_key("method");
    if !has_method && !has_result && !has_error {
        errors.push("must have method, result, or error.".to_string());
    }
    ValidationResult {
        valid: errors.is_empty(),
        errors,
    }
}

// Keep `fmt` imported for the format! macro used in escaper/float formatting;
// this also documents that the module intentionally avoids any external deps.
impl fmt::Display for Json {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Compact form for debug/Display convenience.
        f.write_str(&self.to_json_string(0))
    }
}

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 →