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.
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.
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.
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.
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()));
}
}
rustup) is installed.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.
shorten does.301/302 redirect for every case — browsers and some HTTP clients are allowed to change a POST into a GET when following those codes.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.
#[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.
What it expects
curl -X POST :8081/shorten -d 'https://example.com/a/very/long/path'What it returns
HTTP/1.1 307 Temporary Redirect
Location: https://example.com/a/very/long/path6 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:8081.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: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
404Content-Length: 0, which the code above always does — if you modify the response building, make sure that header is never dropped.// 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 click counting. Track how many times each code has been visited. (Teaches: extending the shared state under the same Mutex.)
- 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.)
- Let the caller choose a custom code. Accept an optional custom slug in the POST body. (Teaches: validating user input against existing keys.)
307 redirect makes over a 301/302. Related: Concurrency & Threads, Collections.