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

URL Shortener

A complete small HTTP service: POST a long URL, get a short code back, and GET the code to be redirected to the original. Hand-rolled sockets, a Mutex-guarded map, and a real collision-avoidance loop.

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

1 The Problem

We want a URL shortener: give it a long link, it returns a short code; give back the code, it returns the original link. It teaches two-way lookups (code↔URL), generating unique keys, and persisting a small store — the core of any link service.

Where this shows up: bit.ly and every link shortener, QR-code targets, affiliate links, any system that maps a short key to a longer value — which includes caches, session stores, and lookup services generally.

2 How to Think About It

Two operations, one shared map: shorten a URL into a code, and expand a code back into its URL.

The plan — in plain English
1. On POST /shorten, generate a random code, checking it is not already taken. → 2. Store code → URL in a shared, Mutex-guarded map. → 3. On GET /{code}, look the code up and reply with a real HTTP redirect. → 4. If the code is unknown, reply 404.

Long URL comes in

Generate a short code

Save code to URL mapping

Return the short code

Short code comes in

Look up the URL

Return the long URL

3 The Build — explained part by part

Here is the complete service, built on the same hand-rolled HTTP server shape as the REST API project — a raw TcpListener, one thread per connection, and a Mutex-guarded store, since axum is unreachable here.

Rustsrc/main.rs
use std::collections::HashMap;
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};

const CODE_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const CODE_LEN: usize = 6;

/// Same dependency-free stand-in for randomness used by the guessing game
/// and password generator projects — see either of those for why the
/// `rand` crate, the idiomatic choice, is unreachable in this build
/// environment.
fn random_char() -> u8 {
    let n = RandomState::new().build_hasher().finish();
    CODE_CHARS[(n % CODE_CHARS.len() as u64) as usize]
}

fn make_code() -> String {
    (0..CODE_LEN).map(|_| random_char() as char).collect()
}

/// The shared map is `code -> original URL`, guarded by one `Mutex` behind
/// an `Arc` so every connection's thread can read and write it safely —
/// the same pattern the rest-api project uses, and for the same reason:
/// Rust's standard-library HTTP handling is thread-per-connection, so
/// without the lock two shortens arriving at once could corrupt the map.
struct Store {
    urls: HashMap<String, String>,
}

impl Store {
    fn new() -> Self {
        Store { urls: HashMap::new() }
    }

    /// Keeps generating a fresh code until it finds one not already in use
    /// — collisions are astronomically unlikely at this scale, but a real
    /// system should never assume "unlikely" means "impossible."
    fn shorten(&mut self, url: String) -> String {
        loop {
            let code = make_code();
            if !self.urls.contains_key(&code) {
                self.urls.insert(code.clone(), url);
                return code;
            }
        }
    }

    fn expand(&self, code: &str) -> Option<&String> {
        self.urls.get(code)
    }
}

struct Response {
    status: &'static str,
    headers: Vec<(String, String)>,
    body: String,
}

fn handle_request(store: &Arc<Mutex<Store>>, method: &str, path: &str, body: &str) -> Response {
    match (method, path) {
        ("POST", "/shorten") => {
            let url = body.trim();
            if url.is_empty() {
                return Response { status: "400 Bad Request", headers: vec![], body: "empty body".into() };
            }
            let mut store = store.lock().unwrap();
            let code = store.shorten(url.to_string());
            Response {
                status: "201 Created",
                headers: vec![],
                body: format!("http://127.0.0.1:8081/{code}"),
            }
        }
        ("GET", path) if path.len() > 1 => {
            let code = &path[1..];
            let store = store.lock().unwrap();
            match store.expand(code) {
                Some(url) => Response {
                    status: "307 Temporary Redirect",
                    headers: vec![("Location".to_string(), url.clone())],
                    body: String::new(),
                },
                None => Response { status: "404 Not Found", headers: vec![], body: "no such short link".into() },
            }
        }
        _ => Response { status: "404 Not Found", headers: vec![], body: "no such route".into() },
    }
}

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 mut out = format!(
        "HTTP/1.1 {}\r\nContent-Length: {}\r\n",
        response.status,
        response.body.len()
    );
    for (name, value) in &response.headers {
        out.push_str(&format!("{name}: {value}\r\n"));
    }
    out.push_str("Connection: close\r\n\r\n");
    out.push_str(&response.body);
    let _ = stream.write_all(out.as_bytes());
}

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8081").expect("could not bind to :8081");
    let store = Arc::new(Mutex::new(Store::new()));
    println!("Listening on http://127.0.0.1:8081");

    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::*;

    #[test]
    fn shortens_and_expands_a_url() {
        let mut store = Store::new();
        let code = store.shorten("https://example.com/a/very/long/path".to_string());
        assert_eq!(code.len(), CODE_LEN);
        assert_eq!(store.expand(&code).unwrap(), "https://example.com/a/very/long/path");
    }

    #[test]
    fn expanding_an_unknown_code_returns_none() {
        let store = Store::new();
        assert!(store.expand("nosuch").is_none());
    }

    #[test]
    fn shorten_never_reuses_a_code_already_in_use() {
        let mut store = Store::new();
        // Force a collision: pre-fill the map so the first attempt must retry.
        store.urls.insert("AAAAAA".to_string(), "https://taken.example".to_string());
        let code = store.shorten("https://new.example".to_string());
        // Whatever code comes back, it must map to the NEW url, and the
        // pre-existing "AAAAAA" entry must be untouched.
        assert_eq!(store.expand(&code).unwrap(), "https://new.example");
        assert_eq!(store.expand("AAAAAA").unwrap(), "https://taken.example");
    }

    #[test]
    fn http_route_end_to_end_through_handle_request() {
        let store = Arc::new(Mutex::new(Store::new()));
        let shorten = handle_request(&store, "POST", "/shorten", "https://example.com");
        assert_eq!(shorten.status, "201 Created");
        let code = shorten.body.rsplit('/').next().unwrap().to_string();

        let redirect = handle_request(&store, "GET", &format!("/{code}"), "");
        assert_eq!(redirect.status, "307 Temporary Redirect");
        assert_eq!(redirect.headers[0], ("Location".to_string(), "https://example.com".to_string()));
    }
}
⚠ 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
fn shorten(&mut self, url: String) -> String { loop { ... } } — keeps generating a fresh random code until it finds one not already in the map. At 6 characters from a 62-character alphabet there are over 56 billion possible codes, so a collision is astronomically unlikely at this scale — but “unlikely” is not “impossible,” and the loop costs almost nothing, so there is no reason to skip checking.

random_char() — the same RandomState-hashing stand-in used by the guessing game and password generator, since the idiomatic rand crate is unreachable here.

307 Temporary Redirect + a Location header — the actual HTTP mechanism a browser uses to follow a short link to its real destination. 307 specifically preserves the original request method on the redirect, unlike a 301/302, which is the more correct choice for a general-purpose redirect service.

Response { status, headers: Vec<(String, String)>, body } — unlike the REST API project, this response type carries extra headers, because a redirect is defined entirely by its Location header, not its body.
Common mistakes — and how to avoid them
✗ Generating a code and storing it without checking whether it is already taken — rare, but a real collision would silently overwrite someone else’s short link.
✓ Loop until you generate a code that is not already a key in the map, as shorten does.
✗ Returning a 301/302 redirect for every case — browsers and some HTTP clients are allowed to change a POST into a GET when following those codes.
✓ Use 307 Temporary Redirect (or 308 for a permanent one) when the request method must be preserved.

4 Test & Prove Each Part

We test the shortening/expansion logic directly, including the collision-avoidance path, plus one end-to-end pass through the routing function.

A shortened URL expands back to the original
Expanding an unknown code returns nothing, not a crash
A pre-existing code is never silently overwritten by a new collision
The full POST /shorten -> GET /{code} flow returns a real redirect with the right Location header
Rustsrc/main.rs (tests module)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn shortens_and_expands_a_url() {
        let mut store = Store::new();
        let code = store.shorten("https://example.com/a/very/long/path".to_string());
        assert_eq!(code.len(), CODE_LEN);
        assert_eq!(store.expand(&code).unwrap(), "https://example.com/a/very/long/path");
    }

    #[test]
    fn expanding_an_unknown_code_returns_none() {
        let store = Store::new();
        assert!(store.expand("nosuch").is_none());
    }

    #[test]
    fn shorten_never_reuses_a_code_already_in_use() {
        let mut store = Store::new();
        // Force a collision: pre-fill the map so the first attempt must retry.
        store.urls.insert("AAAAAA".to_string(), "https://taken.example".to_string());
        let code = store.shorten("https://new.example".to_string());
        // Whatever code comes back, it must map to the NEW url, and the
        // pre-existing "AAAAAA" entry must be untouched.
        assert_eq!(store.expand(&code).unwrap(), "https://new.example");
        assert_eq!(store.expand("AAAAAA").unwrap(), "https://taken.example");
    }

    #[test]
    fn http_route_end_to_end_through_handle_request() {
        let store = Arc::new(Mutex::new(Store::new()));
        let shorten = handle_request(&store, "POST", "/shorten", "https://example.com");
        assert_eq!(shorten.status, "201 Created");
        let code = shorten.body.rsplit('/').next().unwrap().to_string();

        let redirect = handle_request(&store, "GET", &format!("/{code}"), "");
        assert_eq!(redirect.status, "307 Temporary Redirect");
        assert_eq!(redirect.headers[0], ("Location".to_string(), "https://example.com".to_string()));
    }
}

Run with cargo test. The collision test is the interesting one: it pre-fills the map with a specific code, then calls shorten and asserts the pre-existing entry survives untouched — proving the retry loop actually protects against overwrites rather than just looking like it does.

5 The Interface

Verified against a real running server with real curl requests.

INPUTPOST /shortenlong URL as the request body
What it expects
curl -X POST :8081/shorten -d 'https://example.com/a/very/long/path'
OUTPUTGET /{code}307 redirect to the original URL
What it returns
HTTP/1.1 307 Temporary Redirect
Location: https://example.com/a/very/long/path

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:8081.

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:8081
$ curl -X POST :8081/shorten -d 'https://example.com/a/very/long/path'
http://127.0.0.1:8081/c0Qtnr
$ curl -D - -o /dev/null :8081/c0Qtnr
HTTP/1.1 307 Temporary Redirect
Location: https://example.com/a/very/long/path
$ curl -o /dev/null -w '%{http_code}\n' :8081/doesnotexist
404
If it breaks — how to fix it
🚨 could not bind to :8081
Something else is already listening on port 8081, possibly the rest-api project if it is still running on a different port — check with a process list.
🚨 curl just hangs.
A GET with no trailing content still needs the server to send Content-Length: 0, which the code above always does — if you modify the response building, make sure that header is never dropped.
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 click counting. Track how many times each code has been visited. (Teaches: extending the shared state under the same Mutex.)
  2. Persist to a file. Save the map to disk like the to-do-list project does, so links survive a restart. (Teaches: combining file persistence with a running server.)
  3. Let the caller choose a custom code. Accept an optional custom slug in the POST body. (Teaches: validating user input against existing keys.)
What you learned
You learned to build a complete small HTTP service from a raw socket up, including a real collision-avoidance loop for generated identifiers and the difference a 307 redirect makes over a 301/302. Related: Concurrency & Threads, Collections.