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_jsonYour Cargo.toml now includes:
[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
}#[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.
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:
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:
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:
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())),
}
}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:
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
- Add a
created_atfield toTodousing a timestamp string. Set it inStore::create. Mark it#[serde(skip_deserializing)]so clients can't set it and#[serde(rename = "createdAt")]for the JSON key. - Add query parameter support to
GET /api/todos:/api/todos?completed=trueshould filter to only completed todos. Parse the query string and use.filter()on the list. - 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. - Implement
PATCH /api/todos/:idas a separate method fromPUT. What's the semantic difference between PUT and PATCH in REST? - Add
#[serde(deny_unknown_fields)]toCreateTodo. 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.