rust 65 lines · 8 steps

Building a JSON-RPC 2.0 handler in Axum

An Axum handler that parses, dispatches, and answers single or batched JSON-RPC 2.0 calls with spec-compliant responses.

Explained by highlit
1use axum::{extract::State, response::IntoResponse, Json};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use std::sync::Arc;
5 
6#[derive(Deserialize)]
7struct RpcRequest {
8 jsonrpc: String,
9 method: String,
10 #[serde(default)]
11 params: Value,
12 id: Option<Value>,
13}
14 
15#[derive(Serialize)]
16struct RpcResponse {
17 jsonrpc: &'static str,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 result: Option<Value>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 error: Option<Value>,
22 id: Value,
23}
24 
25async fn dispatch(state: &AppState, req: RpcRequest) -> RpcResponse {
26 let id = req.id.clone().unwrap_or(Value::Null);
27 if req.jsonrpc != "2.0" {
28 return RpcResponse { jsonrpc: "2.0", result: None, id, error: Some(json!({ "code": -32600, "message": "Invalid Request" })) };
29 }
30 let outcome = match req.method.as_str() {
31 "account.balance" => state.balance(req.params).await,
32 "account.transfer" => state.transfer(req.params).await,
33 "ping" => Ok(json!("pong")),
34 other => Err(json!({ "code": -32601, "message": format!("Method not found: {other}") })),
35 };
36 match outcome {
37 Ok(result) => RpcResponse { jsonrpc: "2.0", result: Some(result), error: None, id },
38 Err(error) => RpcResponse { jsonrpc: "2.0", result: None, error: Some(error), id },
39 }
40}
41 
42pub async fn rpc_handler(State(state): State<Arc<AppState>>, Json(payload): Json<Value>) -> impl IntoResponse {
43 match payload {
44 Value::Array(batch) if !batch.is_empty() => {
45 let mut responses = Vec::with_capacity(batch.len());
46 for entry in batch {
47 match serde_json::from_value::<RpcRequest>(entry) {
48 Ok(req) => responses.push(dispatch(&state, req).await),
49 Err(_) => responses.push(RpcResponse {
50 jsonrpc: "2.0", result: None, id: Value::Null,
51 error: Some(json!({ "code": -32700, "message": "Parse error" })),
52 }),
53 }
54 }
55 Json(json!(responses))
56 }
57 single => match serde_json::from_value::<RpcRequest>(single) {
58 Ok(req) => Json(json!(dispatch(&state, req).await)),
59 Err(_) => Json(json!(RpcResponse {
60 jsonrpc: "2.0", result: None, id: Value::Null,
61 error: Some(json!({ "code": -32700, "message": "Parse error" })),
62 })),
63 },
64 }
65}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Modeling the wire protocol as typed structs lets serde enforce the shape while you focus on dispatch logic.
  2. 2JSON-RPC's spec-defined error codes give clients a stable contract even when parsing or method lookup fails.
  3. 3Handling batch and single requests through one shared dispatch path keeps behavior consistent across both forms.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building a JSON-RPC 2.0 handler in Axum — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code