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 →