Skip to content

Chapter 14

Working with JSON & Serde

Turn our HTML server into an API server. Serde makes serialization and deserialization almost effortless - derive a macro and your structs speak JSON.

Adding serde and serde_json

Serde is Rust's de facto serialization framework. It's split into two crates: serde (the core traits and derive macros) and format-specific crates like serde_json for JSON. This design means the same derive macros work for JSON, TOML, YAML, MessagePack, and dozens of other formats.

cargo add serde --features derive
cargo add serde_json

Your Cargo.toml now includes:

Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros"] }

The derive feature enables #[derive(Serialize, Deserialize)] - the macros that generate serialization code at compile time. Without this feature, you'd have to implement the traits manually (which you almost never want to do).

#[derive(Serialize, Deserialize)]

Adding Serialize and Deserialize to a struct makes it automatically convertible to and from JSON (and any other Serde-supported format):

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Todo {
    id: u64,
    title: String,
    completed: bool,
}

fn main() {
    // Struct → JSON string
    let todo = Todo {
        id: 1,
        title: "Learn Serde".to_string(),
        completed: false,
    };

    let json = serde_json::to_string(&todo).unwrap();
    println!("{}", json);
    // {"id":1,"title":"Learn Serde","completed":false}

    // Pretty-printed
    let pretty = serde_json::to_string_pretty(&todo).unwrap();
    println!("{}", pretty);

    // JSON string → Struct
    let input = r#"{"id":2,"title":"Build API","completed":true}"#;
    let parsed: Todo = serde_json::from_str(input).unwrap();
    println!("{:?}", parsed);
    // Todo { id: 2, title: "Build API", completed: true }
}

That's it. No hand-written parsing, no manual field mapping. The derive macro inspects your struct's fields at compile time and generates all the serialization code. It works with nested structs, enums, vectors, hashmaps, options - anything Serde knows about.

Serde Attributes

Attributes let you customize how fields are serialized:

#[derive(Debug, Serialize, Deserialize)]
struct Todo {
    id: u64,

    title: String,

    #[serde(default)]              // Use bool::default() (false) if missing
    completed: bool,

    #[serde(skip_serializing_if = "Option::is_none")]  // Omit if None
    description: Option<String>,

    #[serde(rename = "createdAt")] // JSON uses camelCase
    created_at: String,
}

// Or rename all fields at once:
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
    status_code: u16,     // → "statusCode" in JSON
    error_message: String, // → "errorMessage" in JSON
}
Tip
#[serde(rename_all = "camelCase")] is the most common attribute - APIs typically use camelCase while Rust uses snake_case. Other options: "SCREAMING_SNAKE_CASE", "kebab-case", "PascalCase".

Enums in JSON

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]  // Use internally tagged representation
enum ApiResult {
    #[serde(rename = "success")]
    Success { data: Todo },

    #[serde(rename = "error")]
    Error { message: String },
}

// Serializes as:
// {"type":"success","data":{"id":1,"title":"...","completed":false}}
// {"type":"error","message":"not found"}

Parsing JSON Request Bodies

When a client sends a POST request with a JSON body, we need to: extract the body from the raw request, check the Content-Type header, and deserialize into a struct. Let's add JSON parsing to our server:

use crate::error::ServerError;

/// Extract and parse a JSON body from the raw request.
fn parse_json_body<T: serde::de::DeserializeOwned>(
    raw_request: &str,
    headers: &std::collections::HashMap<String, String>,
) -> Result<T, ServerError> {
    // Check Content-Type
    let content_type = headers.get("content-type")
        .ok_or_else(|| ServerError::ParseError(
            "missing Content-Type header".into()
        ))?;

    if !content_type.contains("application/json") {
        return Err(ServerError::ParseError(
            format!("expected application/json, got {}", content_type)
        ));
    }

    // Extract body (everything after \r\n\r\n)
    let body = raw_request.split("\r\n\r\n")
        .nth(1)
        .ok_or_else(|| ServerError::ParseError("no request body".into()))?;

    // Deserialize
    serde_json::from_str(body).map_err(|e| {
        ServerError::ParseError(format!("invalid JSON: {}", e))
    })
}

The function is generic over T: DeserializeOwned - it works with any struct that implements Deserialize. The DeserializeOwned bound means the deserialized type owns all its data (no borrowed references into the JSON string). This is what you want for request bodies.

Note
serde_json::from_str returns detailed error messages. If a field is missing, the wrong type, or the JSON is malformed, the error says exactly what went wrong: "missing field `title` at line 1 column 23". We pass these through to the client as 400 responses.

Returning JSON Responses

Let's add a convenience method to Response for JSON:

src/response.rs
impl Response {
    // ... existing methods ...

    /// Create a JSON response from any serializable value.
    pub fn json<T: serde::Serialize>(status: u16, reason: &'static str, data: &T) -> Self {
        let body = serde_json::to_string(data).unwrap_or_else(|e| {
            format!(r#"{{"error":"serialization failed: {}"}}"#, e)
        });
        let mut resp = Self::new(status, reason, &body);
        resp.add_header("Content-Type", "application/json");
        resp
    }

    /// 200 OK with JSON body
    pub fn json_ok<T: serde::Serialize>(data: &T) -> Self {
        Self::json(200, "OK", data)
    }

    /// 201 Created with JSON body
    pub fn json_created<T: serde::Serialize>(data: &T) -> Self {
        Self::json(201, "Created", data)
    }
}

Now handlers can return JSON responses with a single call:

fn list_todos(req: &Request) -> Result<Response, ServerError> {
    let todos = vec![
        Todo { id: 1, title: "Learn Rust".into(), completed: true },
        Todo { id: 2, title: "Build API".into(), completed: false },
    ];
    Ok(Response::json_ok(&todos))
}

// Response:
// HTTP/1.1 200 OK
// Content-Type: application/json
// Content-Length: 89
//
// [{"id":1,"title":"Learn Rust","completed":true},{"id":2,"title":"Build API","completed":false}]

Building a Simple REST API: CRUD for Todos

Let's build a complete CRUD (Create, Read, Update, Delete) API. We need in-memory storage shared across requests. Since we're using async with Tokio, we'll use Arc<Mutex> for the shared state:

src/store.rs
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
    pub id: u64,
    pub title: String,
    #[serde(default)]
    pub completed: bool,
}

/// Input for creating a new todo (no id - server assigns it).
#[derive(Debug, Deserialize)]
pub struct CreateTodo {
    pub title: String,
}

/// Input for updating an existing todo.
#[derive(Debug, Deserialize)]
pub struct UpdateTodo {
    pub title: Option<String>,
    pub completed: Option<bool>,
}

pub struct Store {
    todos: Mutex<HashMap<u64, Todo>>,
    next_id: Mutex<u64>,
}

impl Store {
    pub fn new() -> Self {
        Store {
            todos: Mutex::new(HashMap::new()),
            next_id: Mutex::new(1),
        }
    }

    pub fn list(&self) -> Vec<Todo> {
        let todos = self.todos.lock().unwrap();
        let mut list: Vec<Todo> = todos.values().cloned().collect();
        list.sort_by_key(|t| t.id);
        list
    }

    pub fn get(&self, id: u64) -> Option<Todo> {
        let todos = self.todos.lock().unwrap();
        todos.get(&id).cloned()
    }

    pub fn create(&self, input: CreateTodo) -> Todo {
        let mut next_id = self.next_id.lock().unwrap();
        let id = *next_id;
        *next_id += 1;

        let todo = Todo {
            id,
            title: input.title,
            completed: false,
        };

        self.todos.lock().unwrap().insert(id, todo.clone());
        todo
    }

    pub fn update(&self, id: u64, input: UpdateTodo) -> Option<Todo> {
        let mut todos = self.todos.lock().unwrap();
        let todo = todos.get_mut(&id)?;

        if let Some(title) = input.title {
            todo.title = title;
        }
        if let Some(completed) = input.completed {
            todo.completed = completed;
        }

        Some(todo.clone())
    }

    pub fn delete(&self, id: u64) -> bool {
        self.todos.lock().unwrap().remove(&id).is_some()
    }
}

Notice the separation: Todo has both Serialize and Deserialize (it goes both ways). CreateTodo only has Deserialize (it's only ever read from JSON). UpdateTodo uses Option fields - a missing field means "don't change."

Now the API handlers:

src/handler.rs
use std::sync::Arc;

use crate::error::ServerError;
use crate::request::{Method, Request};
use crate::response::Response;
use crate::router::Router;
use crate::store::{Store, CreateTodo, UpdateTodo};

pub fn build_router(store: Arc<Store>) -> Router {
    let mut router = Router::new();

    // HTML pages
    router.add(Method::Get, "/", |_req| {
        Ok(Response::html("<h1>Todo API</h1><p>Try GET /api/todos</p>"))
    });

    // API routes
    {
        let store = Arc::clone(&store);
        router.add_fn(Method::Get, "/api/todos", move |_req| {
            let todos = store.list();
            Ok(Response::json_ok(&todos))
        });
    }

    {
        let store = Arc::clone(&store);
        router.add_fn(Method::Post, "/api/todos", move |req| {
            let input: CreateTodo = req.json_body()?;
            let todo = store.create(input);
            Ok(Response::json_created(&todo))
        });
    }

    router
}

// For dynamic routes like /api/todos/42, we need path parsing.
// We'll add a simple version here:

pub fn handle_todo_by_id(
    req: &Request,
    store: &Store,
) -> Result<Response, ServerError> {
    // Extract ID from path: "/api/todos/42" → 42
    let id: u64 = req.path
        .strip_prefix("/api/todos/")
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| ServerError::ParseError("invalid todo ID".into()))?;

    match &req.method {
        Method::Get => {
            let todo = store.get(id)
                .ok_or_else(|| ServerError::NotFound(
                    format!("todo {}", id)
                ))?;
            Ok(Response::json_ok(&todo))
        }
        Method::Put => {
            let input: UpdateTodo = req.json_body()?;
            let todo = store.update(id, input)
                .ok_or_else(|| ServerError::NotFound(
                    format!("todo {}", id)
                ))?;
            Ok(Response::json_ok(&todo))
        }
        Method::Delete => {
            if store.delete(id) {
                Ok(Response::json(200, "OK", &serde_json::json!({
                    "deleted": true
                })))
            } else {
                Err(ServerError::NotFound(format!("todo {}", id)))
            }
        }
        _ => Err(ServerError::ParseError("method not allowed".into())),
    }
}
Note
serde_json::json! is a macro that creates a serde_json::Value from JSON-like syntax. It's handy for one-off responses where defining a struct would be overkill: json!({"status": "ok", "count": 42}).

We also need a helper on Request to parse JSON bodies:

src/request.rs
impl Request {
    // ... existing methods ...

    /// Parse the request body as JSON.
    pub fn json_body<T: serde::de::DeserializeOwned>(&self) -> Result<T, ServerError> {
        let body = self.body.as_ref()
            .ok_or_else(|| ServerError::ParseError("missing request body".into()))?;

        serde_json::from_str(body).map_err(|e| {
            ServerError::ParseError(format!("invalid JSON: {}", e))
        })
    }
}

Test the API with curl:

# List todos (empty)
curl -s http://localhost:8080/api/todos | jq
# []

# Create a todo
curl -s -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Serde"}' | jq
# {"id":1,"title":"Learn Serde","completed":false}

# Create another
curl -s -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Build REST API"}' | jq

# List all
curl -s http://localhost:8080/api/todos | jq

# Get one
curl -s http://localhost:8080/api/todos/1 | jq

# Update (mark as completed)
curl -s -X PUT http://localhost:8080/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}' | jq
# {"id":1,"title":"Learn Serde","completed":true}

# Delete
curl -s -X DELETE http://localhost:8080/api/todos/1 | jq
# {"deleted":true}

# Try to get deleted todo
curl -s http://localhost:8080/api/todos/1
# {"status":404,"message":"todo 1 not found"}

# Bad JSON
curl -s -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"wrong":"field"}' | jq
# {"status":400,"message":"invalid JSON: missing field `title`..."}

JSON Error Responses

Our API should return JSON errors, not HTML. Let's update ServerError::to_response to detect API routes:

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

impl ServerError {
    pub fn to_response(&self, is_api: bool) -> Response {
        let (status, reason, message) = match self {
            ServerError::Io(e) =>
                (500, "Internal Server Error", format!("server error: {}", e)),
            ServerError::ParseError(msg) =>
                (400, "Bad Request", msg.clone()),
            ServerError::NotFound(what) =>
                (404, "Not Found", format!("{} not found", what)),
        };

        if is_api {
            Response::json(status, reason, &ErrorResponse {
                status,
                message,
            })
        } else {
            let mut resp = Response::new(
                status, reason,
                &format!("<h1>{}</h1><p>{}</p>", status, message),
            );
            resp.add_header("Content-Type", "text/html; charset=utf-8");
            resp
        }
    }
}

Now API endpoints get clean JSON errors and browser-facing pages get HTML errors. The connection handler checks if the path starts with /api/ to decide:

let is_api = request.path.starts_with("/api/");

let response = match result {
    Ok(resp) => resp,
    Err(e) => e.to_response(is_api),
};

Exercise

  1. Add a created_at field to Todo using a timestamp string. Set it in Store::create. Mark it #[serde(skip_deserializing)] so clients can't set it and #[serde(rename = "createdAt")] for the JSON key.
  2. Add query parameter support to GET /api/todos: /api/todos?completed=true should filter to only completed todos. Parse the query string and use .filter() on the list.
  3. Add validation to CreateTodo: the title must be non-empty and under 200 characters. Return a 400 error with a descriptive message if validation fails.
  4. Implement PATCH /api/todos/:id as a separate method from PUT. What's the semantic difference between PUT and PATCH in REST?
  5. Add #[serde(deny_unknown_fields)] to CreateTodo. Send a request with an extra field and observe the error. When would you want this vs allowing extra fields?

What's Next

Our server is now a real API - it accepts JSON bodies, returns JSON responses, and provides a full CRUD interface for todos. Serde handles all the serialization boilerplate with a single derive macro.

But the data lives only in memory - restart the server and it's gone. In the next chapter, we add file I/O and static file serving - reading files from disk, detecting MIME types, and serving a frontend alongside our API.