Skip to content

JSON-RPC Request Builder — Go source

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

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

// Package jsonrpc is the Go twin of CosmoDev's src/lib/jsonRpc.ts (dual source:
// the web lib is TypeScript, the CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. The table-driven tests in
// json-rpc-builder_test.go share vectors with src/lib/jsonRpc.test.ts so the
// two implementations are held to the same contract.
//
// The twin builds JSON-RPC 2.0 requests, notifications, success/error
// responses, and batches, mirroring the TS lib's public surface, defaults, and
// edge-case handling exactly. JSON output uses encoding/json; because struct
// fields are marshaled in declaration order, key ordering matches the TS
// objects (which follow JS insertion order).
package jsonrpc

import (
	"encoding/json"
	"reflect"
	"strings"
)

// JSONRPCVersion is the JSON-RPC protocol version produced by every message,
// mirroring JSONRPC_VERSION in src/lib/jsonRpc.ts.
const JSONRPCVersion = "2.0"

// RpcID is a JSON-RPC id: a string, number, or null. It is an alias for any
// because Go has no union type; callers pass a string, a number (int/float),
// or nil for null — matching TS `RpcId = string | number | null`.
type RpcID = any

// Options mirrors BuildOptions in the TS lib. The zero value (Options{})
// matches the TS default: compact JSON (indent omitted → indent ?? 0 → 0).
type Options struct {
	Indent int // <=0 → compact, matching TS indent ?? 0
}

// Outcome mirrors the TS Outcome: OK reports whether the build succeeded, JSON
// holds the serialized message ("" on failure), and Error holds a failure
// reason ("" when OK). It never represents a panic.
type Outcome struct {
	OK    bool
	JSON  string
	Error string
}

// ValidationResult mirrors { valid, errors } from validateRpc in the TS lib.
type ValidationResult struct {
	Valid  bool
	Errors []string
}

// StandardErrorCode carries the conventional message for a reserved code.
type StandardErrorCode struct {
	Message string
}

// StandardErrors mirrors STANDARD_ERRORS in the TS lib: the conventional
// message for each reserved JSON-RPC error code.
var StandardErrors = map[int]StandardErrorCode{
	-32700: {Message: "Parse error"},
	-32600: {Message: "Invalid Request"},
	-32601: {Message: "Method not found"},
	-32602: {Message: "Invalid params"},
	-32603: {Message: "Internal error"},
	-32000: {Message: "Server error"},
}

// Internal message shapes. Field declaration order is the JSON key order,
// matching the TS objects' JS insertion order. Optional fields (params, result,
// data) use omitempty so a nil value omits the key — mirroring TS, where the
// builder only sets the key when the value is not undefined.

type rpcRequest struct {
	JSONRPC string `json:"jsonrpc"`
	Method  string `json:"method"`
	Params  any    `json:"params,omitempty"`
	ID      RpcID  `json:"id"`
}

type rpcNotification struct {
	JSONRPC string `json:"jsonrpc"`
	Method  string `json:"method"`
	Params  any    `json:"params,omitempty"`
}

type rpcSuccess struct {
	JSONRPC string `json:"jsonrpc"`
	Result  any    `json:"result,omitempty"`
	ID      RpcID  `json:"id"`
}

type rpcErrorBody struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
	Data    any    `json:"data,omitempty"`
}

type rpcError struct {
	JSONRPC string       `json:"jsonrpc"`
	Error   rpcErrorBody `json:"error"`
	ID      RpcID        `json:"id"`
}

// safeStringify mirrors safeStringify() in the TS lib: compact JSON for
// indent <= 0, otherwise N-space pretty-printing identical to
// JSON.stringify(obj, null, indent). A marshal error is returned rather than
// panicking, so callers can surface it in an Outcome.
func safeStringify(v any, indent int) (string, error) {
	var b []byte
	var err error
	if indent > 0 {
		b, err = json.MarshalIndent(v, "", strings.Repeat(" ", indent))
	} else {
		b, err = json.Marshal(v)
	}
	if err != nil {
		return "", err
	}
	return string(b), nil
}

// BuildRequest mirrors buildRequest() in the TS lib. method must be non-empty
// (the TS non-string guard is enforced by Go's type system; only the empty
// case remains). params may be nil to omit the field. id is always emitted —
// the TS default of 1 is supplied by the caller (or the cosmodev CLI layer).
func BuildRequest(method string, params any, id RpcID, opts Options) Outcome {
	if method == "" {
		return Outcome{OK: false, JSON: "", Error: "method must be a non-empty string"}
	}
	obj := rpcRequest{JSONRPC: JSONRPCVersion, Method: method, Params: params, ID: id}
	s, err := safeStringify(obj, opts.Indent)
	if err != nil {
		return Outcome{OK: false, JSON: "", Error: err.Error()}
	}
	return Outcome{OK: true, JSON: s, Error: ""}
}

// BuildNotification mirrors buildNotification() in the TS lib: like a request
// but with no id (notifications are fire-and-forget).
func BuildNotification(method string, params any, opts Options) Outcome {
	if method == "" {
		return Outcome{OK: false, JSON: "", Error: "method must be a non-empty string"}
	}
	obj := rpcNotification{JSONRPC: JSONRPCVersion, Method: method, Params: params}
	s, err := safeStringify(obj, opts.Indent)
	if err != nil {
		return Outcome{OK: false, JSON: "", Error: err.Error()}
	}
	return Outcome{OK: true, JSON: s, Error: ""}
}

// BuildSuccessResponse mirrors buildSuccessResponse() in the TS lib: a result
// response carrying the id of the originating request.
func BuildSuccessResponse(id RpcID, result any, opts Options) Outcome {
	obj := rpcSuccess{JSONRPC: JSONRPCVersion, Result: result, ID: id}
	s, err := safeStringify(obj, opts.Indent)
	if err != nil {
		return Outcome{OK: false, JSON: "", Error: err.Error()}
	}
	return Outcome{OK: true, JSON: s, Error: ""}
}

// BuildErrorResponse mirrors buildErrorResponse() in the TS lib. message and
// data are optional: an empty message falls back to the standard message for
// the code (then to "Error"), and a nil data value omits the data field.
func BuildErrorResponse(id RpcID, code int, message string, data any, opts Options) Outcome {
	msg := message
	if msg == "" {
		if e, ok := StandardErrors[code]; ok {
			msg = e.Message
		} else {
			msg = "Error"
		}
	}
	obj := rpcError{
		JSONRPC: JSONRPCVersion,
		Error:   rpcErrorBody{Code: code, Message: msg, Data: data},
		ID:      id,
	}
	s, err := safeStringify(obj, opts.Indent)
	if err != nil {
		return Outcome{OK: false, JSON: "", Error: err.Error()}
	}
	return Outcome{OK: true, JSON: s, Error: ""}
}

// BuildBatch mirrors buildBatch() in the TS lib: serializes a non-empty slice
// of pre-built messages as a JSON array. A non-array or empty array is rejected
// with the canonical error, matching TS Array.isArray + length checks.
func BuildBatch(messages any, opts Options) Outcome {
	v := reflect.ValueOf(messages)
	kind := v.Kind()
	if kind != reflect.Slice && kind != reflect.Array {
		return Outcome{OK: false, JSON: "", Error: "batch must be a non-empty array"}
	}
	if v.Len() == 0 {
		return Outcome{OK: false, JSON: "", Error: "batch must be a non-empty array"}
	}
	s, err := safeStringify(messages, opts.Indent)
	if err != nil {
		return Outcome{OK: false, JSON: "", Error: err.Error()}
	}
	return Outcome{OK: true, JSON: s, Error: ""}
}

// ValidateRpc mirrors validateRpc() in the TS lib. It checks an in-memory
// object (a map[string]interface{} as produced by encoding/json) against the
// JSON-RPC 2.0 structural rules, collecting every violation. Anything that is
// not an object (nil, numbers, strings, booleans, arrays) is rejected with
// "Not an object." — matching the TS typeof guard.
func ValidateRpc(obj any) ValidationResult {
	m, ok := obj.(map[string]any)
	if !ok {
		return ValidationResult{Valid: false, Errors: []string{"Not an object."}}
	}

	var errs []string

	if m["jsonrpc"] != JSONRPCVersion {
		errs = append(errs, `jsonrpc must be "2.0".`)
	}
	_, hasMethod := m["method"]
	if hasMethod {
		if _, isStr := m["method"].(string); !isStr {
			errs = append(errs, "method must be a string.")
		}
	}
	_, hasResult := m["result"]
	_, hasError := m["error"]
	if hasResult && hasError {
		errs = append(errs, "cannot have both result and error.")
	}
	if !hasMethod && !hasResult && !hasError {
		errs = append(errs, "must have method, result, or error.")
	}

	return ValidationResult{Valid: len(errs) == 0, Errors: errs}
}

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 →