LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
RustIntermediate~2.5 hours

Simple REST API

Build a small JSON API that manages a list of resources in memory, applying everything from structs to error handling.

StructsCollectionsError Handling

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.

What You'll Build
  • 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 Mutexes (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.

MethodPathDescription
GET/resourcesList every resource currently in memory
GET/resources/:idFetch a single resource by id
POST/resourcesCreate a resource from a JSON body
DELETE/resources/:idDelete 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

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.