Overview
This project assumes the `tiny_http = "0.12"` crate for the HTTP server itself, plus `serde = { version = "1", features = ["derive"] }` and `serde_json = "1"` for JSON. `tiny_http` is a deliberately minimal, synchronous, thread-per-request HTTP library — it has no routing, no middleware, and no async runtime, which means every part of handling a request is code you write yourself in this tutorial rather than framework magic. That makes it a better teaching tool here than something like `axum` or `actix-web`, even though either of those would be a more typical production choice.
The one genuinely new idea in this project is *shared mutable state across threads*. Every earlier project in this course used a single-threaded `loop` where one `TaskManager` or `ContactBook` was only ever touched from `main()`. An HTTP server is different: `tiny_http` hands each incoming connection to its own OS thread, and every one of those threads needs to read and write the *same* in-memory list of resources. Rust will not let two threads hold a plain `&mut Vec<Resource>` to the same data at the same time — instead, this project wraps the data in `Arc<Mutex<AppData>>`, and you will see exactly what each of those two types is doing and why neither one alone would be enough.
- A `Resource` struct with `#[derive(Serialize, Deserialize)]` for automatic JSON conversion.
- An `AppData` struct holding a `Vec<Resource>` and a `next_id` counter, shared via `Arc<Mutex<AppData>>`.
- A `tiny_http::Server` accepting connections and dispatching each one onto its own thread.
- `GET /resources` and `GET /resources/:id` handlers returning JSON.
- A `POST /resources` handler that parses a JSON request body with `serde_json`.
- A `DELETE /resources/:id` handler and a small router matching method + path.
Prerequisites
- Everything from the earlier projects in this course — structs, `Result`, ownership, and `HashMap`/`Vec`.
- Basic familiarity with HTTP — methods (GET/POST/DELETE), status codes, and JSON request/response bodies.
- Traits at a conceptual level — what `#[derive(...)]` does and why a struct needs `Serialize`/`Deserialize` to convert to/from JSON.
- `Arc<T>` and `Mutex<T>` — that both exist specifically to let multiple threads safely share one piece of data.
- Closures — `std::thread::spawn(move || { ... })` and what the `move` keyword does to the closure's captures.
Project Structure
Create the project with `cargo new resource_api` and add the three dependencies above to `Cargo.toml`. Everything lives in `src/main.rs`: the `Resource` and `NewResource` structs and their derives, the `AppData` struct and the `SharedState` type alias built around it, one handler function per HTTP method (`list_resources`, `get_resource`, `create_resource`, `delete_resource`), a small `handle_request()` router, and `main()`, which binds the server and spawns one thread per incoming request.
Every handler function takes `&SharedState` (a reference to the `Arc<Mutex<AppData>>`, not an owned copy of it) and returns a `String` of JSON — handlers never write directly to the HTTP response, they just produce a body, and `handle_request()` is the only place that actually calls `request.respond(...)`. That separation is what makes each handler trivially testable on its own, independent of any real HTTP connection.
Step 1: Model the Resource With Serde
`#[derive(Serialize, Deserialize)]` is what lets `serde_json::to_string(&resource)` and `serde_json::from_str::<Resource>(&body)` work without writing any conversion code by hand — the derive macro generates it at compile time based on the struct's fields. `NewResource` is a second, smaller struct with only `name` and `description`: the client sending a POST body should never be able to specify their own `id`, since assigning ids is the server's job, the same rule `TaskManager` and `Bank` followed in earlier projects.
use serde::{Deserialize, Serialize};
// Serialize lets serde_json::to_string(&resource) work; Deserialize lets// serde_json::from_str::<Resource>(json) work. Both are generated by the// derive macro at compile time -- no hand-written JSON code needed.#[derive(Debug, Clone, Serialize, Deserialize)]struct Resource { id: u32, name: String, description: String,}
// Only name/description are accepted from the client. There is no id field// here on purpose: the server assigns ids itself in create_resource() (Step 5),// so a client can never request or overwrite a specific id.#[derive(Debug, Deserialize)]struct NewResource { name: String, description: String,}Step 2: Define Shared Application State
`AppData` bundles the resource list and the id counter into one struct so both are protected by a single `Mutex` — using two separate `Mutex`es (one for the `Vec`, one for the counter) would let one thread observe them briefly out of sync with each other. `Arc` (Atomically Reference Counted) is what allows every worker thread to hold its own handle to the *same* `AppData` — cloning an `Arc` bumps a shared, thread-safe reference count and is cheap; it never copies the `AppData` itself. `Mutex` is what makes it safe for more than one thread to touch that shared data at all: only the thread currently holding the lock may read or write it, and every other thread calling `.lock()` blocks until that lock is released.
use std::sync::{Arc, Mutex};
// Bundled into one struct (not two separate fields behind two separate// Mutexes) so that assigning an id and pushing the new Resource always// happen while holding a single, consistent lock -- see create_resource()// in Step 5.struct AppData { resources: Vec<Resource>, next_id: u32,}
impl AppData { fn new() -> AppData { AppData { resources: Vec::new(), next_id: 1 } }}
// Arc lets many threads each hold a cheap, cloned handle to the SAME// AppData. Mutex ensures only one thread at a time can actually read or// write through that handle. Neither alone would be enough: Arc without// Mutex allows concurrent access with no protection; Mutex without Arc// can't be shared across threads in the first place.type SharedState = Arc<Mutex<AppData>>;Step 3: Start the HTTP Server and Accept Connections
`Server::http(...)` binds a TCP listener and returns a `tiny_http::Server`; `server.incoming_requests()` is a blocking iterator that yields one `Request` per connection. `Arc::clone(&state)` inside the loop is the key line: it does not clone `AppData`, it clones the `Arc` handle pointing at it, which is why passing shared state into a new thread is cheap even though the underlying `Vec<Resource>` might be large. The `move` keyword on the closure is required because the spawned thread must own its copy of `state` and `request` for as long as it runs, potentially after `main()`'s loop has moved on to accept the next connection.
use tiny_http::Server;
fn main() { let server = Server::http("127.0.0.1:8080").expect("failed to bind to port 8080"); println!("Listening on http://127.0.0.1:8080");
let state: SharedState = Arc::new(Mutex::new(AppData::new()));
for request in server.incoming_requests() { // Blocks here until a new connection arrives let state = Arc::clone(&state); // Cheap: bumps a reference count, does NOT copy AppData itself std::thread::spawn(move || { // move: this thread must own its `state` and `request` handles handle_request(request, state); }); }}Step 4: Handle GET Requests
`state.lock()` returns a `LockResult<MutexGuard<AppData>>`; `.unwrap()` on it only panics if some other thread already panicked while holding the lock (a "poisoned" mutex), which is treated here as an unrecoverable programming error, not a normal failure to handle gracefully. While the returned `MutexGuard` is alive, this thread — and only this thread — may read or write the `AppData` it wraps; the guard releases the lock automatically when it goes out of scope at the end of the function.
fn list_resources(state: &SharedState) -> String { let data = state.lock().unwrap(); // Blocks until no other thread holds the lock; releases it when `data` drops serde_json::to_string(&data.resources).unwrap_or_else(|_| "[]".to_string())}
fn get_resource(state: &SharedState, id_str: &str) -> String { let id: u32 = match id_str.parse() { Ok(n) => n, Err(_) => return json_error("invalid id"), };
let data = state.lock().unwrap(); match data.resources.iter().find(|r| r.id == id) { // Borrowed search, same pattern as ContactBook::find() Some(resource) => serde_json::to_string(resource).unwrap_or_else(|_| json_error("serialization failed")), None => json_error("resource not found"), }}
// Small shared helper: every handler below returns its error bodies through// this one function so the JSON error shape stays consistent everywhere.fn json_error(message: &str) -> String { format!("{{\"error\":\"{message}\"}}")}Step 5: Handle POST Requests
`create_resource()` reads the raw request body into a `String` with `request.as_reader().read_to_string(...)`, then hands that text to `serde_json::from_str::<NewResource>(...)`, which either succeeds with a `NewResource` or fails with a `serde_json::Error` if the body is not valid JSON or is missing a required field. Assigning `id` and inserting the new `Resource` both happen while the same `MutexGuard` (`data`) is held, which is exactly why `AppData` bundled `resources` and `next_id` into one struct behind one `Mutex` back in Step 2 — no other thread can observe an id being handed out without the matching resource existing yet.
use std::io::Read;
fn create_resource(request: &mut tiny_http::Request, state: &SharedState) -> String { let mut body = String::new(); if request.as_reader().read_to_string(&mut body).is_err() { return json_error("could not read request body"); }
let new_resource: NewResource = match serde_json::from_str(&body) { Ok(nr) => nr, Err(_) => return json_error("invalid JSON body"), };
let mut data = state.lock().unwrap(); // Mutable guard: this thread now has exclusive access to AppData let id = data.next_id; let resource = Resource { id, name: new_resource.name, description: new_resource.description }; data.resources.push(resource.clone()); // Push a clone; the original `resource` is still needed for the response below data.next_id += 1; // Same guard, same lock -- no other thread can hand out this id meanwhile
serde_json::to_string(&resource).unwrap_or_else(|_| json_error("serialization failed"))}Step 6: Handle DELETE Requests
`delete_resource()` reuses the same `retain()` pattern the CLI Task Manager project used to delete-by-predicate: keep every resource whose id does not match, and check whether the length actually shrank to know whether anything was removed. The whole operation — reading `resources.len()`, calling `retain()`, comparing lengths again — happens under one lock acquisition, so no other thread can insert or delete a resource in the middle of it.
fn delete_resource(state: &SharedState, id_str: &str) -> String { let id: u32 = match id_str.parse() { Ok(n) => n, Err(_) => return json_error("invalid id"), };
let mut data = state.lock().unwrap(); let before = data.resources.len(); data.resources.retain(|r| r.id != id); // Same delete-by-predicate pattern as TaskManager::remove_task() if data.resources.len() < before { "{\"status\":\"deleted\"}".to_string() } else { json_error("resource not found") }}Step 7: Wire Up the Request Router
`handle_request()` splits the URL path on `/` into segments and matches on `(method, segments)` together, so the routing table reads almost exactly like the table below it. Rust's slice patterns (`["resources"]`, `["resources", id]`) let a `match` compare both the *shape* (how many segments) and the *content* (a literal segment like `"resources"`) of a `Vec<&str>` in one arm, which is what makes this small hand-written router readable without a routing crate.
| Method | Path | Description |
|---|---|---|
| GET | /resources | List every resource currently in memory |
| GET | /resources/:id | Fetch a single resource by id |
| POST | /resources | Create a resource from a JSON body |
| DELETE | /resources/:id | Delete a resource by id |
use tiny_http::{Header, Method, Request, Response};
fn handle_request(mut request: Request, state: SharedState) { let method = request.method().clone(); let url = request.url().to_string(); let segments: Vec<&str> = url.trim_matches('/').split('/').collect();
// Matching (method, segments.as_slice()) together lets one match express // the whole routing table -- method AND path shape AND path content, // all in a single readable arm per route. let body = match (method, segments.as_slice()) { (Method::Get, ["resources"]) => list_resources(&state), (Method::Get, ["resources", id]) => get_resource(&state, id), (Method::Post, ["resources"]) => create_resource(&mut request, &state), (Method::Delete, ["resources", id]) => delete_resource(&state, id), _ => json_error("not found"), };
let content_type = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(); let response = Response::from_string(body).with_header(content_type);
// Ignoring the Result here: a failed write means the client already // disconnected, and there is nothing more useful this thread can do // about that before it exits. let _ = request.respond(response);}Complete Code
Here is the full program assembled in the correct order. Add `tiny_http = "0.12"`, `serde = { version = "1", features = ["derive"] }`, and `serde_json = "1"` to `Cargo.toml`, save this as `src/main.rs`, and run with `cargo run`.
use serde::{Deserialize, Serialize};use std::io::Read;use std::sync::{Arc, Mutex};use tiny_http::{Header, Method, Request, Response, Server};
#[derive(Debug, Clone, Serialize, Deserialize)]struct Resource { id: u32, name: String, description: String,}
#[derive(Debug, Deserialize)]struct NewResource { name: String, description: String,}
struct AppData { resources: Vec<Resource>, next_id: u32,}
impl AppData { fn new() -> AppData { AppData { resources: Vec::new(), next_id: 1 } }}
type SharedState = Arc<Mutex<AppData>>;
fn json_error(message: &str) -> String { format!("{{\"error\":\"{message}\"}}")}
fn list_resources(state: &SharedState) -> String { let data = state.lock().unwrap(); serde_json::to_string(&data.resources).unwrap_or_else(|_| "[]".to_string())}
fn get_resource(state: &SharedState, id_str: &str) -> String { let id: u32 = match id_str.parse() { Ok(n) => n, Err(_) => return json_error("invalid id"), };
let data = state.lock().unwrap(); match data.resources.iter().find(|r| r.id == id) { Some(resource) => serde_json::to_string(resource).unwrap_or_else(|_| json_error("serialization failed")), None => json_error("resource not found"), }}
fn create_resource(request: &mut Request, state: &SharedState) -> String { let mut body = String::new(); if request.as_reader().read_to_string(&mut body).is_err() { return json_error("could not read request body"); }
let new_resource: NewResource = match serde_json::from_str(&body) { Ok(nr) => nr, Err(_) => return json_error("invalid JSON body"), };
let mut data = state.lock().unwrap(); let id = data.next_id; let resource = Resource { id, name: new_resource.name, description: new_resource.description }; data.resources.push(resource.clone()); data.next_id += 1;
serde_json::to_string(&resource).unwrap_or_else(|_| json_error("serialization failed"))}
fn delete_resource(state: &SharedState, id_str: &str) -> String { let id: u32 = match id_str.parse() { Ok(n) => n, Err(_) => return json_error("invalid id"), };
let mut data = state.lock().unwrap(); let before = data.resources.len(); data.resources.retain(|r| r.id != id); if data.resources.len() < before { "{\"status\":\"deleted\"}".to_string() } else { json_error("resource not found") }}
fn handle_request(mut request: Request, state: SharedState) { let method = request.method().clone(); let url = request.url().to_string(); let segments: Vec<&str> = url.trim_matches('/').split('/').collect();
let body = match (method, segments.as_slice()) { (Method::Get, ["resources"]) => list_resources(&state), (Method::Get, ["resources", id]) => get_resource(&state, id), (Method::Post, ["resources"]) => create_resource(&mut request, &state), (Method::Delete, ["resources", id]) => delete_resource(&state, id), _ => json_error("not found"), };
let content_type = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(); let response = Response::from_string(body).with_header(content_type); let _ = request.respond(response);}
fn main() { let server = Server::http("127.0.0.1:8080").expect("failed to bind to port 8080"); println!("Listening on http://127.0.0.1:8080");
let state: SharedState = Arc::new(Mutex::new(AppData::new()));
for request in server.incoming_requests() { let state = Arc::clone(&state); std::thread::spawn(move || { handle_request(request, state); }); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `PUT /resources/:id` handler that fully replaces an existing resource's fields.
- Return proper HTTP status codes (`201 Created`, `404 Not Found`, `400 Bad Request`) instead of always responding `200 OK` with a JSON error body.
- Persist `AppData` to a JSON file on every write with `serde_json::to_writer`, and load it back in `main()` on startup.
- Add basic query-string filtering, e.g. `GET /resources?name=Widget`, by parsing `request.url()` beyond the path.
- Swap `tiny_http` for `axum` and compare how much of the routing, JSON parsing, and shared-state code this tutorial wrote by hand a framework provides for you.
Summary
You built a small JSON API where `serde` handles the mechanical work of converting between `Resource` structs and JSON text, and `Arc<Mutex<AppData>>` is what makes it safe for many threads — one per incoming connection — to share and mutate the same in-memory resource list without corrupting it. The `Arc` clones a cheap handle, the `Mutex` enforces that only one thread touches the underlying data at a time, and every handler you wrote stayed a plain function taking `&SharedState` and returning a `String`, independent of any particular HTTP request. That is the same combination of ownership discipline and small, composable functions this whole course has been building toward, now applied to genuinely concurrent code.