10 · Capstone: URL Shortener Service¶
This capstone pulls together the whole path: a multi-file Cargo project, a
persistent SQLite-backed axum service (Level 3), structured logging (Level
4 module 04), and a real algorithm with its own unit tests (Level 4 module
06) — a URL shortener. POST /links takes a long URL and returns a short
code; GET /{code} redirects to the original URL and counts the hit;
GET /links/{code}/stats reports how many times a code has been used.
Project layout¶
capstone/
├── Cargo.toml
└── src/
├── main.rs # wiring: router, shared state, tracing setup
├── models.rs # Link / NewLink structs
├── codegen.rs # id -> short code algorithm, with its own tests
├── db.rs # all SQL
└── handlers.rs # axum handlers
cargo new capstone
cd capstone
cargo add axum
cargo add tokio --features full
cargo add rusqlite --features bundled
cargo add serde --features derive
cargo add tracing tracing-subscriber
src/codegen.rs — id-to-code, and a bug caught by its own tests¶
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
/// Turns a monotonically increasing row id into a short base-36 code.
/// Deterministic and collision-free as long as ids never repeat.
pub fn encode(mut id: u64) -> String {
if id == 0 {
return "0".to_string();
}
let mut out = Vec::new();
while id > 0 {
let rem = (id % ALPHABET.len() as u64) as usize;
out.push(ALPHABET[rem]);
id /= ALPHABET.len() as u64;
}
out.reverse();
String::from_utf8(out).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_known_values() {
assert_eq!(encode(0), "0");
assert_eq!(encode(1), "1");
assert_eq!(encode(35), "z");
assert_eq!(encode(36), "10");
}
#[test]
fn encoding_is_unique_over_a_range() {
let mut seen = std::collections::HashSet::new();
for id in 0..10_000u64 {
assert!(seen.insert(encode(id)), "collision at id {id}");
}
}
}
Worth telling honestly: the first version of ALPHABET here was ordered
"abcdefghijklmnopqrstuvwxyz0123456789" (letters first), which made
encode(1) return "b" instead of the intended "1" — a plain off-by-index
mistake, not a subtle one, but exactly the kind cargo test exists to catch
before it ships:
thread 'codegen::tests::encodes_known_values' panicked at src/codegen.rs:26:9:
assertion `left == right` failed
left: "b"
right: "1"
Reordering ALPHABET to put digits first ("0123...z") fixed both the
known-value test and, as a bonus, the "over a range" collision test that was
also failing for the same underlying reason:
$ cargo test
running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
src/db.rs — insert, resolve-and-count, stats¶
use crate::models::Link;
use rusqlite::{params, Connection, Result};
pub fn init(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
hits INTEGER NOT NULL DEFAULT 0
)",
(),
)?;
Ok(())
}
pub fn insert_link(conn: &Connection, url: &str) -> Result<Link> {
conn.execute("INSERT INTO links (code, url) VALUES ('', ?1)", params![url])?;
let id = conn.last_insert_rowid() as u64;
let code = crate::codegen::encode(id);
conn.execute("UPDATE links SET code = ?1 WHERE id = ?2", params![code, id as i64])?;
Ok(Link { code, url: url.to_string(), hits: 0 })
}
pub fn resolve(conn: &Connection, code: &str) -> Result<Option<Link>> {
let found = conn.query_row(
"SELECT code, url, hits FROM links WHERE code = ?1",
params![code],
|row| Ok(Link { code: row.get(0)?, url: row.get(1)?, hits: row.get(2)? }),
);
match found {
Ok(link) => {
conn.execute("UPDATE links SET hits = hits + 1 WHERE code = ?1", params![code])?;
Ok(Some(link))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
insert_link inserts first with a placeholder empty code to get an
auto-incrementing id from SQLite, then derives the short code from that
id and updates the row — the code depends on the id, so the id has to exist
first. rusqlite::Error::QueryReturnedNoRows gets converted to Ok(None)
in resolve, same pattern as the Level 3 REST API project: a missing code
is an expected outcome, not a database error, and the function's signature
(Result<Option<Link>>) reflects that distinction directly in its type.
src/handlers.rs and src/main.rs — wiring¶
pub async fn redirect(
State(state): State<AppState>,
Path(code): Path<String>,
) -> Result<Redirect, StatusCode> {
let conn = state.conn.lock().unwrap();
match db::resolve(&conn, &code) {
Ok(Some(link)) => Ok(Redirect::temporary(&link.url)),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
let app = Router::new()
.route("/links", post(handlers::shorten))
.route("/links/{code}/stats", get(handlers::link_stats))
.route("/{code}", get(handlers::redirect))
.with_state(state);
Redirect::temporary returns a 307 Temporary Redirect — deliberately not
301 Permanent, because a permanent redirect tells browsers to cache the
mapping and stop even asking the server on future visits, which would make
the hit counter stop incrementing after the first browser visit per client.
Running it¶
$ curl -s -X POST localhost:3004/links -H 'content-type: application/json' \
-d '{"url":"https://www.rust-lang.org"}'
{"code":"b","url":"https://www.rust-lang.org","hits":0}
$ curl -s -X POST localhost:3004/links -H 'content-type: application/json' \
-d '{"url":"https://doc.rust-lang.org/book"}'
{"code":"c","url":"https://doc.rust-lang.org/book","hits":0}
$ curl -s -o /dev/null -w "redirect status=%{http_code}\n" localhost:3004/b
redirect status=307
$ curl -s -o /dev/null -w "redirect status=%{http_code}\n" localhost:3004/b
redirect status=307
$ curl -s localhost:3004/links/b/stats
{"code":"b","url":"https://www.rust-lang.org","hits":2}
$ curl -s -o /dev/null -w "unknown code status=%{http_code}\n" localhost:3004/zzzz
unknown code status=404
Two visits to /b bumped hits from 0 to 2, visible in the stats
endpoint — the whole loop (create, redirect, count, report) working against
a real SQLite file on disk, not a mock.
Design decisions worth naming explicitly¶
Codes are derived from row ids, not random. This guarantees no
collisions without a retry loop, at the cost of codes being sequential and
guessable (b, c, d, ...) — fine for an internal tool, wrong for a
public shortener where guessable codes leak how many links exist and let
someone enumerate them. A production version would use a random component
(e.g., a few bytes from rand) and handle the rare collision with a retry.
The hit counter increments inside resolve, tied to redirect, not to
stats. Calling /links/{code}/stats doesn't itself count as a hit — only
an actual redirect does. Mixing those up (incrementing on every stats check
too) would make the metric measure "how often someone checked" instead of
"how often someone used the link."
One Mutex<Connection>, same tradeoff as the Level 3 project. Every
handler serializes on the same lock; fine at this scale, and the same
r2d2_sqlite-pool stretch goal from Level 3 applies here too if throughput
ever mattered.
How It Actually Works¶
This capstone is a synthesis point for the whole path's ownership story, and
it's worth naming precisely which mechanisms are load-bearing where.
Deriving codes from row ids rather than generating them randomly with a
collision-retry loop sidesteps a genuinely hard concurrency problem: a
retry loop needs to check-then-insert, and under concurrent requests hitting
the same shared Mutex<Connection> (this project's single-connection
tradeoff, same as Level 3's), a naive check-then-insert without the whole
sequence being atomic under one lock acquisition would be a real
time-of-check-to-time-of-use race — the id-derived scheme avoids needing
that atomicity at all, since SQLite itself guarantees each inserted row gets
a unique, monotonically increasing id as part of the insert.
The hit counter incrementing specifically inside resolve (not stats)
works because it's a plain UPDATE ... SET hits = hits + 1 executed while
holding the same Mutex<Connection> every other handler acquires — there's
no separate atomic counter or cache to keep in sync with the database the
way an AtomicU64 (Level 4, module 04) would need to be; the lock guarantees
each increment sees the true current row value with no other handler's
write interleaved. This is the one-shared-connection design's actual
guarantee: correctness by full serialization, at the throughput cost the
"design decisions" section already names and the r2d2_sqlite pooling
stretch goal exists to relax.
Stretch goals¶
- Add a
DELETE /links/{code}endpoint and confirm a deleted code correctly 404s on both/{code}redirect and/links/{code}/statsafterward. - Add an optional custom-alias field to
NewLink(POST /linkswith{"url": "...", "alias": "my-link"}) that uses the given alias instead of the generated code, returning409 Conflictif the alias is already taken — this requires changingdb::insert_link's uniqueness handling since theUNIQUEconstraint oncodewill now sometimes fail on purpose. - Add a
created_attimestamp column and an endpoint listing the 10 most recently created links. - Swap the sequential id-based
encodefor a randomized code (using therandcrate) with a bounded retry loop onUNIQUEconstraint violation, and write a test that forces a collision to confirm the retry actually works rather than just trusting it does.