← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Rust Project

REST API

A real, hand-rolled HTTP server over a raw TcpListener, with thread-per-connection concurrency and a Mutex-guarded store. Rust's flagship use case, built from first principles.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every mobile app backend, every single-page web app, every microservice, every public API (Stripe, Twitter, GitHub). REST is the lingua franca of the internet. Understanding it is essential to backend work.

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.

The plan — in plain English
1. Define the shared 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.

GET

POST

PUT

DELETE

Request arrives

Which method?

Return records as JSON

Create a record

Update a record

Remove a record

Send JSON response

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.

Rustsrc/main.rs
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""#);
    }
}
⚠ No in-browser playground here
Rust compiles to a real binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once Rust (via rustup) is installed.
What each part does — in plain words
struct Store { todos: HashMap<u32, Todo>, next_id: u32 } wrapped in Arc<Mutex<Store>> — every connection runs on its own OS thread (see below), so without a lock, two requests arriving at once could corrupt the map or hand out a duplicate id. 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.
Common mistakes — and how to avoid them
✗ Sharing a 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.
✓ Wrap shared mutable state in Arc<Mutex<...>>, as Store is here.
✗ Forgetting to read exactly 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.
✓ Parse 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.

Posting a task creates it and it shows up in the list
Deleting a todo succeeds once, then 404s on the same id
Posting with no task field is rejected with 400
An unknown route returns 404
The narrow JSON parser correctly unescapes an escaped quote
Rustsrc/main.rs (tests module)
#[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.

INPUTPOST /todoscreate a task
What it expects
curl -X POST :8080/todos -d '{"task":"Buy milk"}'
OUTPUTGET /todoslist tasks as JSON
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.

Run it locally
cargo run
Starts 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.

What you should see when it works
Terminala real run
$ 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'
404
If it breaks — how to fix it
🚨 could not bind to :8080
Something else is already listening on port 8080 — stop it, or change the port in both main and your curl commands.
🚨 {"error":"missing \"task\" field"}
The POST body was not valid JSON containing a "task" key, or Content-Type confused the client — the parser here only looks for the literal text "task":"...".
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. Add a PATCH /todos/{id} route to toggle done. (Teaches: extending the route match.)
  2. Use the real axum and serde crates. If you have network access, rewrite this with them and compare how much boilerplate disappears. (Teaches: what a modern async framework buys you.)
  3. Add request logging. Print method, path and status for every request. (Teaches: simple middleware-style wrapping.)
What you learned
You learned what a web framework does underneath: parsing a raw HTTP request off a socket, routing by method and path, and guarding shared state with 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.