Backend: POST Endpoints, JSON, and Error Handling

Building on the read-only API, this covers writing endpoints that accept input and may fail.

What Changed

We added two POST endpoints:

.route("/tx", post(submit_tx))
.route("/accounts", post(create_account))

These accept JSON request bodies and can return errors. Three new concepts:

  1. Json<T> extractor — auto-deserialize request body
  2. #[derive(Deserialize)] — let serde build a struct from JSON
  3. Result<T, E> as return type — handle success vs error in one signature

Serde: JSON ↔ Struct

serde is Rust’s serialization framework. With derive macros, you get JSON ↔ struct conversion for free:

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
struct CreateAccountRequest {
    id: u32,
    balance: u64,
    pubkey: BigUint,
}

You can derive both together: #[derive(Deserialize, Serialize)].

Field types

serde can convert standard types automatically:

Rust typeJSON form
i32, u32, i64, u64number
String, &strstring
boolboolean
Vec<T>array
Option<T>value or null
Structobject
Enumdepends on #[serde(...)] config

BigUint and serde

num-bigint doesn’t implement Serialize/Deserialize by default. Enable it via Cargo feature:

num-bigint = { version = "0.4", features = ["serde"] }

The default format is a Vec<u32> of internal limbs, not a string:

{ "r": [0], "z": [42, 1] }

This is ugly for humans but efficient for programs. To get pretty hex/decimal strings, you’d write a custom serde adapter (skip this for learning).

The Json<T> Extractor

async fn submit_tx(
    State(state): State<SharedState>,
    Json(tx): Json<Transaction>,
) -> Result<String, (StatusCode, String)> {
    // tx is already a deserialized Transaction
}

What axum does behind the scenes:

  1. Reads the request body
  2. Validates Content-Type: application/json
  3. Calls serde_json::from_slice::<Transaction>(body)
  4. If parse fails, returns 400 with the error message — without ever calling your handler
  5. If parse succeeds, your handler runs with the typed value

You don’t have to write boilerplate parsing or error handling for malformed JSON. This is the power of axum’s typed extractors.

Returning Errors: Result<T, E>

Real handlers can fail. axum lets you return Result<T, E> where:

The simplest pattern is (StatusCode, String):

async fn submit_tx(
    State(state): State<SharedState>,
    Json(tx): Json<Transaction>,
) -> Result<String, (StatusCode, String)> {
    let mut s = state.lock().await;
    match s.apply_tx(&tx) {
        Ok(()) => Ok("tx applied".to_string()),
        Err(e) => Err((StatusCode::BAD_REQUEST, format!("{:?}", e))),
    }
}

axum’s IntoResponse is implemented for (StatusCode, String) to produce an HTTP response with that status and body.

Status code conventions

CodeMeaningWhen to use
200 OKSuccessNormal happy path
201 CreatedResource createdAfter POST that creates something
400 Bad RequestClient sent bad dataValidation failures, business rule violations
401 UnauthorizedNot authenticatedMissing/bad token
403 ForbiddenAuthenticated but not allowedPermission denied
404 Not FoundResource doesn’t existAccount ID not in state
409 ConflictConcurrent modificationTwo writers, one fails
500 Internal Server ErrorServer bugUnexpected panic, DB down

For our submit_tx:

Error Pattern Maturity Levels

Level 1: String errors (what we have now)

Err((StatusCode::BAD_REQUEST, format!("{:?}", e)))

Level 2: Typed error response

#[derive(Serialize)]
struct ErrorResponse {
    code: String,
    message: String,
}

Err((StatusCode::BAD_REQUEST, Json(ErrorResponse {
    code: "INSUFFICIENT_BALANCE".to_string(),
    message: format!("{:?}", e),
})))

Level 3: Custom error type implementing IntoResponse

impl IntoResponse for RollupError {
    fn into_response(self) -> Response {
        let (status, code) = match self {
            RollupError::AccountNotFound { .. } => (StatusCode::NOT_FOUND, "NOT_FOUND"),
            RollupError::InsufficientBalance { .. } => (StatusCode::BAD_REQUEST, "INSUFFICIENT_BALANCE"),
            RollupError::InvalidSignature => (StatusCode::UNAUTHORIZED, "INVALID_SIG"),
            // ...
        };
        // build the response
    }
}

// handler signature gets cleaner:
async fn submit_tx(...) -> Result<String, RollupError> {
    s.apply_tx(&tx)?;  // ? converts RollupError to error response automatically
    Ok(...)
}

This is the production-grade pattern. Skip it until you feel the pain of Level 1.

Sending Requests with curl

POST with JSON body

curl -X POST http://localhost:3000/tx \
  -H 'Content-Type: application/json' \
  -d '{"from":1,"to":2,"amount":30,"nonce":1,"proof":{"r":[0],"z":[0]},"challenge_e":[0]}'

Components:

Other useful flags

curl -i ...     # show response headers
curl -v ...     # verbose, show request and response
curl -s ...     # silent (no progress bar)
curl -o file ... # save response body to file

Why Content-Type: application/json matters

axum’s Json<T> extractor checks this header. Without it, axum returns a 415 Unsupported Media Type, never reaching your handler.

Testing the Full Flow

# 1. Check server is alive
curl http://localhost:3000/health
# -> ok

# 2. Inspect initial state
curl http://localhost:3000/state-root
# -> some big number
curl http://localhost:3000/balance/1
# -> 100

# 3. Create a new account
curl -X POST http://localhost:3000/accounts \
  -H 'Content-Type: application/json' \
  -d '{"id":5,"balance":1000,"pubkey":[99]}'
# -> account 5 created

curl http://localhost:3000/balance/5
# -> 1000

# 4. Submit a (fake) tx — should fail with InvalidSignature
curl -X POST http://localhost:3000/tx \
  -H 'Content-Type: application/json' \
  -d '{"from":1,"to":2,"amount":30,"nonce":1,"proof":{"r":[0],"z":[0]},"challenge_e":[0]}'
# -> InvalidSignature

The full pipeline (request parsing → state lock → ZKP logic → response) is now exercised.

Common Pitfalls

1. Forgetting to derive Deserialize

struct Transaction { /* ... */ }  // no derive!

async fn submit_tx(Json(tx): Json<Transaction>) { ... }
// Compile error: Transaction does not implement DeserializeOwned

Always derive Deserialize on types used in Json<T>.

2. Field name mismatch

JSON keys must match Rust field names. To override, use #[serde(rename = "...")]:

#[derive(Deserialize)]
struct CreateAccountRequest {
    id: u32,
    #[serde(rename = "initial_balance")]
    balance: u64,
}

3. Order of extractors matters

// ❌ Json must come last (it consumes the body)
async fn handler(Json(body): Json<Foo>, State(s): State<MyState>) {}

// ✅
async fn handler(State(s): State<MyState>, Json(body): Json<Foo>) {}

Multi-byte body extractors like Json, Form consume the body, so they must come last.

4. Returning the wrong type

async fn handler() -> Json<MyType> { ... }  // returns JSON
async fn handler() -> String { ... }         // returns plain text
async fn handler() -> StatusCode { ... }     // returns just a status

axum picks the response builder based on your return type’s IntoResponse impl.

What’s Next

Possible directions:

Each is a real-world Rust pattern.