1 The Problem
We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.
2 How to Think About It
A REST API is a routing table plus shared state. Build the state and the routing logic first, entirely independent of sockets, then wire sockets on last.
Store (create/list/delete) behind an Arc<Mutex<Store>>. → 2. Write handle_request as pure routing logic: method + path + body in, a status + JSON body out. → 3. Parse a raw HTTP request off a TcpStream by hand. → 4. Spawn one thread per connection so the server handles requests concurrently.
3 The Build — explained part by part
Here is the complete API. A real project would reach for axum or actix-web here — both crates, both unreachable in this build environment — so this project does by hand exactly what those frameworks automate: request parsing, routing, and JSON encoding.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq)]
struct Todo {
id: u32,
task: String,
done: bool,
}
/// Hand-rolled JSON encoding — the idiomatic answer here is `serde` +
/// `serde_json`, unreachable in this build environment (see the to-do-list
/// and cli-task-manager write-ups for the same constraint). Escaping is
/// minimal on purpose: it covers quotes and backslashes, enough for this
/// project's plain-text task names, not full JSON-string escaping.
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn todo_to_json(t: &Todo) -> String {
format!(
r#"{{"id":{},"task":"{}","done":{}}}"#,
t.id,
escape_json(&t.task),
t.done
)
}
fn todos_to_json(todos: &[Todo]) -> String {
format!("[{}]", todos.iter().map(todo_to_json).collect::<Vec<_>>().join(","))
}
/// Just enough of a JSON object parser for this API's one shape:
/// `{"task": "some text"}`. Looks for the `"task"` key and pulls the quoted
/// value after it, unescaping `\"` and `\\`. A real project reaches for
/// `serde_json::from_str` instead.
fn parse_task_field(body: &str) -> Option<String> {
let key_pos = body.find("\"task\"")?;
let after_key = &body[key_pos + "\"task\"".len()..];
let colon_pos = after_key.find(':')?;
let after_colon = after_key[colon_pos + 1..].trim_start();
let after_quote = after_colon.strip_prefix('"')?;
let mut result = String::new();
let mut chars = after_quote.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(result),
'\\' => {
if let Some(next) = chars.next() {
result.push(next);
}
}
_ => result.push(c),
}
}
None
}
/// All the mutable state behind one `Mutex`, shared across connections via
/// `Arc`. This server handles each connection on its own OS thread — Rust's
/// standard library ships a real, if bare-bones, concurrent HTTP story
/// without needing an async runtime — so without this lock two requests
/// arriving at once could corrupt `todos` or hand out a duplicate id.
struct Store {
todos: HashMap<u32, Todo>,
next_id: u32,
}
impl Store {
fn new() -> Self {
Store { todos: HashMap::new(), next_id: 1 }
}
fn create(&mut self, task: String) -> Todo {
let todo = Todo { id: self.next_id, task, done: false };
self.todos.insert(todo.id, todo.clone());
self.next_id += 1;
todo
}
fn list(&self) -> Vec<Todo> {
let mut items: Vec<Todo> = self.todos.values().cloned().collect();
items.sort_by_key(|t| t.id);
items
}
fn delete(&mut self, id: u32) -> bool {
self.todos.remove(&id).is_some()
}
}
struct Response {
status: &'static str,
body: String,
}
fn respond(status: &'static str, body: String) -> Response {
Response { status, body }
}
/// The whole route table for this tiny API, kept separate from socket
/// handling so it can be tested by calling it directly with a fake request.
fn handle_request(store: &Arc<Mutex<Store>>, method: &str, path: &str, body: &str) -> Response {
match (method, path) {
("GET", "/todos") => {
let store = store.lock().unwrap();
respond("200 OK", todos_to_json(&store.list()))
}
("POST", "/todos") => match parse_task_field(body) {
Some(task) => {
let mut store = store.lock().unwrap();
let todo = store.create(task);
respond("201 Created", todo_to_json(&todo))
}
None => respond("400 Bad Request", r#"{"error":"missing \"task\" field"}"#.to_string()),
},
("DELETE", path) if path.starts_with("/todos/") => {
let id_str = &path["/todos/".len()..];
match id_str.parse::<u32>() {
Ok(id) => {
let mut store = store.lock().unwrap();
if store.delete(id) {
respond("204 No Content", String::new())
} else {
respond("404 Not Found", r#"{"error":"no such todo"}"#.to_string())
}
}
Err(_) => respond("400 Bad Request", r#"{"error":"invalid id"}"#.to_string()),
}
}
_ => respond("404 Not Found", r#"{"error":"no such route"}"#.to_string()),
}
}
/// Parses the request line and, if present, a Content-Length body, from a
/// raw HTTP/1.1 request. A real project would use a framework like `axum`
/// (unreachable here); this is what that framework is doing underneath.
fn read_request(stream: &TcpStream) -> Option<(String, String, String)> {
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
reader.read_line(&mut request_line).ok()?;
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_string();
let path = parts.next()?.to_string();
let mut content_length = 0usize;
loop {
let mut header_line = String::new();
reader.read_line(&mut header_line).ok()?;
let trimmed = header_line.trim();
if trimmed.is_empty() {
break;
}
if let Some(value) = trimmed.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body).ok()?;
}
Some((method, path, String::from_utf8_lossy(&body).to_string()))
}
fn serve_connection(store: &Arc<Mutex<Store>>, mut stream: TcpStream) {
let (method, path, body) = match read_request(&stream) {
Some(r) => r,
None => return,
};
let response = handle_request(store, &method, &path, &body);
let out = format!(
"HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.status,
response.body.len(),
response.body
);
let _ = stream.write_all(out.as_bytes());
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").expect("could not bind to :8080");
let store = Arc::new(Mutex::new(Store::new()));
println!("Listening on http://127.0.0.1:8080");
for incoming in listener.incoming() {
match incoming {
Ok(stream) => {
let store = Arc::clone(&store);
std::thread::spawn(move || serve_connection(&store, stream));
}
Err(e) => eprintln!("Connection failed: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn new_store() -> Arc<Mutex<Store>> {
Arc::new(Mutex::new(Store::new()))
}
#[test]
fn creates_and_lists_todos() {
let store = new_store();
let created = handle_request(&store, "POST", "/todos", r#"{"task":"Buy milk"}"#);
assert_eq!(created.status, "201 Created");
assert!(created.body.contains("\"task\":\"Buy milk\""));
let listed = handle_request(&store, "GET", "/todos", "");
assert_eq!(listed.status, "200 OK");
assert!(listed.body.contains("Buy milk"));
}
#[test]
fn deletes_a_todo_and_404s_on_repeat() {
let store = new_store();
handle_request(&store, "POST", "/todos", r#"{"task":"one"}"#);
let deleted = handle_request(&store, "DELETE", "/todos/1", "");
assert_eq!(deleted.status, "204 No Content");
let deleted_again = handle_request(&store, "DELETE", "/todos/1", "");
assert_eq!(deleted_again.status, "404 Not Found");
}
#[test]
fn rejects_a_post_with_no_task_field() {
let store = new_store();
let result = handle_request(&store, "POST", "/todos", "{}");
assert_eq!(result.status, "400 Bad Request");
}
#[test]
fn unknown_route_is_404() {
let store = new_store();
let result = handle_request(&store, "GET", "/nope", "");
assert_eq!(result.status, "404 Not Found");
}
#[test]
fn parses_a_task_field_with_an_escaped_quote() {
let task = parse_task_field(r#"{"task":"say \"hi\""}"#).unwrap();
assert_eq!(task, r#"say "hi""#);
}
}
rustup) is installed.Arc lets multiple threads share ownership of the same Mutex; the Mutex itself is what actually serializes access.fn handle_request(...) -> Response — the entire route table, written as a plain function that takes a method, path and body and returns a status and JSON body. It knows nothing about sockets, which is exactly why the tests below can call it directly with a fake request and no real network involved.
todo_to_json / parse_task_field — hand-rolled JSON encoding and a narrow, one-shape decoder. A real project reaches for
serde + serde_json here; both are external crates unreachable in this sandbox.std::thread::spawn(move || serve_connection(&store, stream)) — Rust’s standard library has no async runtime built in (that is what crates like
tokio provide), so this server gets its concurrency the plain way: a real OS thread per connection, with Arc::clone giving each thread its own handle to the same shared Store.
HashMap across threads with no Mutex — this will not even compile, because HashMap is not Sync. Rust catches the data race at compile time instead of letting it happen at runtime.Arc<Mutex<...>>, as Store is here.Content-Length bytes for the body — reading until the connection closes instead can hang, since this client sends Connection: close but a browser or another client might not.Content-Length from the headers and call read_exact for precisely that many bytes, as read_request does.4 Test & Prove Each Part
We test the routing logic directly — no real socket involved — by calling handle_request with fake methods, paths and bodies.
#[cfg(test)]
mod tests {
use super::*;
fn new_store() -> Arc<Mutex<Store>> {
Arc::new(Mutex::new(Store::new()))
}
#[test]
fn creates_and_lists_todos() {
let store = new_store();
let created = handle_request(&store, "POST", "/todos", r#"{"task":"Buy milk"}"#);
assert_eq!(created.status, "201 Created");
assert!(created.body.contains("\"task\":\"Buy milk\""));
let listed = handle_request(&store, "GET", "/todos", "");
assert_eq!(listed.status, "200 OK");
assert!(listed.body.contains("Buy milk"));
}
#[test]
fn deletes_a_todo_and_404s_on_repeat() {
let store = new_store();
handle_request(&store, "POST", "/todos", r#"{"task":"one"}"#);
let deleted = handle_request(&store, "DELETE", "/todos/1", "");
assert_eq!(deleted.status, "204 No Content");
let deleted_again = handle_request(&store, "DELETE", "/todos/1", "");
assert_eq!(deleted_again.status, "404 Not Found");
}
#[test]
fn rejects_a_post_with_no_task_field() {
let store = new_store();
let result = handle_request(&store, "POST", "/todos", "{}");
assert_eq!(result.status, "400 Bad Request");
}
#[test]
fn unknown_route_is_404() {
let store = new_store();
let result = handle_request(&store, "GET", "/nope", "");
assert_eq!(result.status, "404 Not Found");
}
#[test]
fn parses_a_task_field_with_an_escaped_quote() {
let task = parse_task_field(r#"{"task":"say \"hi\""}"#).unwrap();
assert_eq!(task, r#"say "hi""#);
}
}
Run with cargo test. Because handle_request takes plain values in and returns a plain Response, every test here runs with zero sockets and zero timing concerns — the same reason Go's version of this project tested its Store methods directly.
5 The Interface
Verified against a real running server with real curl requests, not just the unit tests above.
What it expects
curl -X POST :8080/todos -d '{"task":"Buy milk"}'What it returns
[{"id":1,"task":"Buy milk","done":false}]6 Run It & Automate It
Save the code as src/main.rs inside a Cargo project's src/ folder and run it with cargo run — Cargo compiles and executes in one step while you are experimenting, then cargo build --release gives you an optimized binary once you are done.
cargo runStarts listening on
http://127.0.0.1:8080. Try it with curl in another terminal.A CI tool like Jenkins runs cargo test automatically whenever the code changes — every line below has a plain explanation.
$ cargo run &
Listening on http://127.0.0.1:8080
$ curl -X POST :8080/todos -d '{"task":"Buy milk"}'
{"id":1,"task":"Buy milk","done":false}
$ curl -X POST :8080/todos -d '{"task":"Call mom"}'
{"id":2,"task":"Call mom","done":false}
$ curl :8080/todos
[{"id":1,"task":"Buy milk","done":false},{"id":2,"task":"Call mom","done":false}]
$ curl -X DELETE :8080/todos/1 -o /dev/null -w '%{http_code}\n'
204
$ curl :8080/todos
[{"id":2,"task":"Call mom","done":false}]
$ curl -X DELETE :8080/todos/99 -o /dev/null -w '%{http_code}\n'
404main and your curl commands."task" key, or Content-Type confused the client — the parser here only looks for the literal text "task":"...".// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Rust') {
steps {
sh 'rustc --version' // confirm Rust is installed
sh 'cargo build' // compile, downloading any crates
}
}
stage('Run the tests') {
steps {
sh 'cargo clippy -- -D warnings' // catch obvious mistakes before running
sh 'cargo test' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Add a PATCH /todos/{id} route to toggle
done. (Teaches: extending the route match.) - Use the real
axumandserdecrates. If you have network access, rewrite this with them and compare how much boilerplate disappears. (Teaches: what a modern async framework buys you.) - Add request logging. Print method, path and status for every request. (Teaches: simple middleware-style wrapping.)
Arc<Mutex<T>> across a thread-per-connection server — then proved it against a real running server with real curl requests. Related: Concurrency & Threads, Collections.