Build a Link Shortener in Rust with Axum, Tokio and Postgres
Why a link shortener? In about 550 lines of Rust (comments included) we'll build a link shortener that validates input, counts clicks without race conditions, returns friendly JSON errors, shuts down…
Why a link shortener?
In about 550 lines of Rust (comments included) we'll build a link shortener that validates input, counts clicks without race conditions, returns friendly JSON errors, shuts down gracefully and is tested against a real database. It's the perfect weekend project: small enough to finish, but it touches everything a real web service needs.
Here's the whole API we're building:
| Method | Path | What it does |
|---|---|---|
POST |
/api/links |
Create a short link from a url, with an optional custom_code |
GET |
/{code} |
Redirect to the original URL and count the click |
GET |
/api/links/{code} |
Show a link's details and click count |
GET |
/health |
Report whether the service can reach Postgres |
If you know a bit of Rust and have heard of async, you're ready. We'll go file by file, and every snippet below comes straight from a version that compiles, passes cargo clippy -- -D warnings, and passes all its tests.
The stack
Four main pieces do the heavy lifting, and they were designed to fit together:
| Crate | Version | Its job here |
|---|---|---|
tokio |
1.54 | The async runtime: runs thousands of requests on a handful of threads |
axum |
0.8 | The web framework: routing, extractors, turning return values into responses |
sqlx |
0.9 | Async Postgres driver with a connection pool and embedded migrations |
tower-http |
0.7 | Middleware; we use its TraceLayer for request logs |
A few small helpers round it out: serde for JSON, url for parsing URLs properly, rand for random codes, thiserror for tidy error types, chrono for timestamps and tracing for logs.
Why SQLx rather than an ORM? It lets you write plain SQL and still get typed results back. For a service with three queries, plain SQL is the most readable thing there is.
Every request crosses the same layers, and only store.rs speaks SQL.
flowchart TD
client["Client<br/>curl or a browser; gets a 307 redirect or JSON"]
subgraph app["linkshort on Tokio"]
trace["TraceLayer (tower-http)<br/>logs method, path, status and latency"]
router["Router (lib.rs)<br/>matches the path, hands AppState to the handler"]
handlers["Handlers (routes.rs)<br/>validate input, turn any failure into an AppError"]
store["Store (store.rs)<br/>one async fn per SQL query, through the PgPool"]
apperror["AppError (error.rs)<br/>one JSON error shape with a fitting status"]
end
postgres[("Postgres<br/>links table; UNIQUE code keeps codes distinct")]
client -- "GET /pLZpLr6" --> trace
trace --> router --> handlers --> store
store -- "UPDATE … RETURNING" --> postgres
handlers -- "Err" --> apperror
apperror -- "JSON error, 4xx or 5xx" --> client
A redirect walks straight down this stack and back up. Any failure along the way becomes an AppError, which always leaves as the same JSON shape.
Setting up the project
Start with a fresh crate and pull in the dependencies:
cargo new linkshort && cd linkshort
cargo add tokio --features full
cargo add axum
cargo add sqlx --no-default-features \
--features runtime-tokio,tls-rustls,postgres,macros,migrate,chrono
cargo add serde --features derive
cargo add serde_json chrono --features chrono/serde
cargo add rand url thiserror tracing dotenvy
cargo add tracing-subscriber --features env-filter
cargo add tower-http --features trace
# for the tests
cargo add --dev tower --features util
cargo add --dev http-body-util
We turn off SQLx's default features and pick only what we need: the Tokio runtime, rustls for TLS, the Postgres driver, and migrations.
Next, a Postgres to talk to. A tiny docker-compose.yml does it:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shortener
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Run docker compose up -d db, then put your settings in a .env file. The app reads it at startup with dotenvy:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/shortener
BASE_URL=http://localhost:3000
BIND_ADDR=0.0.0.0:3000
RUST_LOG=linkshort=debug,tower_http=debug
Here's where we're headed. Each file has one job, which keeps every file short:
migrations/ SQL, embedded into the binary at compile time
src/main.rs config, pool, migrations, server, graceful shutdown
src/lib.rs AppState and the router
src/routes.rs HTTP handlers and input validation
src/store.rs every SQL query
src/code.rs short-code generation and custom-code rules
src/error.rs AppError -> JSON responses
src/config.rs environment configuration
tests/api.rs end-to-end tests against real Postgres
Splitting into lib.rs plus a thin main.rs pays off later: the tests can build the exact same router the binary serves.
The schema: one table
The whole data model is one table. Save this as migrations/20261021000000_create_links.sql:
-- One row per short link.
CREATE TABLE links (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE
CHECK (char_length(code) BETWEEN 3 AND 32),
target_url TEXT NOT NULL
CHECK (char_length(target_url) <= 2048),
clicks BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_clicked_at TIMESTAMPTZ
);
A few choices worth calling out:
UNIQUEoncodegives us an index for fast lookups and is the real guarantee that two links never share a code. Our Rust code checks too, but the database has the final word.CHECKconstraints repeat our validation rules in the database. If a bug ever slips past the Rust side, Postgres still refuses bad data.GENERATED ALWAYS AS IDENTITYis the modern replacement forSERIAL.TIMESTAMPTZstores an exact moment in time. PlainTIMESTAMPdrops the time zone, which bites eventually.
We never run this file by hand. The app applies it at startup, which we'll see in a moment.
Short codes people can actually type
A short code is the pLZpLr6 in https://sho.rt/pLZpLr6. People will read these off slides, say them out loud and retype them from screenshots. So we leave out the characters that look alike: 0 O o 1 l I.
// src/code.rs
use rand::RngExt;
/// Characters used for generated codes. We drop `0 O 1 l I o` so codes
/// survive being read aloud, printed on a slide, or typed from a screenshot.
const ALPHABET: &[u8] = b"23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
/// 56^7 is roughly 1.7 trillion possible codes.
pub const CODE_LEN: usize = 7;
/// Paths the router already uses, so a custom code can never shadow them.
const RESERVED: &[&str] = &["api", "health"];
pub fn generate() -> String {
let mut rng = rand::rng();
(0..CODE_LEN)
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
.collect()
}
That leaves 56 characters. Seven of them give about 1.7 trillion combinations, so collisions are rare. They're still possible, and we'll handle them properly in the next section.
A note on rand 0.10: the handy methods like random_range now live on the RngExt trait, so that's the import you need. Older tutorials use rand::Rng with thread_rng(), which won't compile against the current version.
People can also pick their own code, like /rustconf. Those need a few rules:
/// Rules for user-chosen codes: 3 to 32 characters of letters, digits,
/// `-` or `_`, and not one of our reserved paths.
pub fn validate_custom(code: &str) -> Result<(), String> {
if !(3..=32).contains(&code.len()) {
return Err("custom_code must be between 3 and 32 characters".into());
}
if !code
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err("custom_code may only contain letters, digits, '-' and '_'".into());
}
if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(code)) {
return Err(format!("'{code}' is reserved, please pick another"));
}
Ok(())
}
The reserved list matters. Without it, someone could claim the code health, and that link could never be reached, because the /health route would always answer first.
Talking to Postgres
Every SQL query lives in src/store.rs. Handlers call plain async functions and never see SQL, which keeps both sides easy to read.
First, a struct that matches a row. #[derive(sqlx::FromRow)] teaches SQLx to build it from query results:
// src/store.rs
use chrono::{DateTime, Utc};
use sqlx::PgPool;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Link {
pub id: i64,
pub code: String,
pub target_url: String,
pub clicks: i64,
pub created_at: DateTime<Utc>,
pub last_clicked_at: Option<DateTime<Utc>>,
}
Notice the Option on last_clicked_at. The column is nullable, and Rust makes us say so.
Inserting without a race
Here's the tempting way to insert a link: check if the code exists, and if not, insert it. But two requests can both check, both see "free", and both try to insert. Instead, we ask Postgres to do it in one atomic step:
/// Insert a link, or return `None` if the code is already taken.
///
/// `ON CONFLICT DO NOTHING` turns a collision into "zero rows returned"
/// instead of an error, so the caller can simply try another code.
pub async fn insert(pool: &PgPool, code: &str, target_url: &str) -> sqlx::Result<Option<Link>> {
sqlx::query_as::<_, Link>(
r#"
INSERT INTO links (code, target_url)
VALUES ($1, $2)
ON CONFLICT (code) DO NOTHING
RETURNING id, code, target_url, clicks, created_at, last_clicked_at
"#,
)
.bind(code)
.bind(target_url)
.fetch_optional(pool)
.await
}
RETURNING hands back the new row, so we never need a second query. fetch_optional turns "no row" into None. The caller then decides what that means: "try another random code" or "sorry, that custom code is taken".
The $1 and $2 placeholders matter too. Values are sent separately from the SQL text, so SQL injection simply can't happen here. SQLx 0.9 even nudges you this way: query functions only accept &'static str SQL by default.
Counting clicks in one round trip
The redirect is the hot path, so it gets the most care. One UPDATE ... RETURNING finds the link, bumps the counter and gives back the target URL, all at once:
/// Look up a link and count the click in a single round trip.
pub async fn record_click(pool: &PgPool, code: &str) -> sqlx::Result<Option<String>> {
sqlx::query_scalar::<_, String>(
r#"
UPDATE links
SET clicks = clicks + 1, last_clicked_at = now()
WHERE code = $1
RETURNING target_url
"#,
)
.bind(code)
.fetch_optional(pool)
.await
}
clicks = clicks + 1 runs inside the database under a row lock, so concurrent clicks never overwrite each other. Compare that with reading the count into Rust, adding one and writing it back: two simultaneous clicks would both read 41 and both write 42. We'll prove this works with a test that fires 50 clicks at once.
The find function for the stats endpoint is a plain SELECT ... WHERE code = $1, built the same way.
Want compile-time checked SQL? SQLx also has
query!andquery_as!macros that check your SQL against a live database while compiling. They're great, but they needDATABASE_URLset at build time (or a cached.sqlxfolder). We use the runtime functions to keep the build simple.
Errors that are kind to your users
Nothing ruins an API faster than a bare 500 Internal Server Error or an HTML error page when you asked for JSON. We'll put every failure into one enum and teach axum how to turn it into a response.
// src/error.rs
use axum::{
Json,
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
/// Every way a request can fail. Handlers return `Result<_, AppError>` and
/// `?` does the rest.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0}")]
Validation(String),
#[error("no link with that code")]
NotFound,
#[error("{0}")]
Conflict(String),
#[error(transparent)]
BadJson(#[from] JsonRejection),
#[error("could not find a free short code, please try again")]
CodeSpaceExhausted,
#[error(transparent)]
Database(#[from] sqlx::Error),
}
The #[from] attributes are where thiserror shines. They let ? convert a sqlx::Error or a JSON parsing failure into an AppError automatically.
Now the part that makes it all work. Implementing IntoResponse means any handler can return Result<T, AppError>, and axum knows what to send:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
AppError::BadJson(rejection) => (rejection.status(), rejection.body_text()),
AppError::CodeSpaceExhausted => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
AppError::Database(err) => {
// Log the real cause, but never leak SQL details to clients.
tracing::error!(error = %err, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"something went wrong on our side".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
Every error now has the same shape, {"error": "..."}, with a status code that means something:
| Status | When |
|---|---|
| 400 | The body isn't valid JSON |
| 404 | No link with that code |
| 409 | That custom code is already taken |
| 422 | Valid JSON, but the values break a rule (bad URL, unknown field, reserved code) |
| 500 | The database failed; the details go to our logs, not to the client |
| 503 | We couldn't find a free random code after 5 tries |
The database arm is the one to remember. Database errors can contain table names, constraint names, even fragments of data. We log all of it for ourselves and tell the client only that something went wrong.
The handlers
This is where axum feels magical. A handler is just an async function. Its arguments are extractors that pull pieces out of the request, and its return value becomes the response.
Creating a link
The request body is a small struct. deny_unknown_fields catches typos: send customCode instead of custom_code and you get a clear error, not a silently ignored field.
// src/routes.rs
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateLink {
pub url: String,
pub custom_code: Option<String>,
}
And here's the handler:
/// `POST /api/links`
pub async fn create_link(
State(state): State<AppState>,
payload: Result<Json<CreateLink>, JsonRejection>,
) -> Result<impl IntoResponse, AppError> {
let Json(req) = payload?;
let target = validate_target(&req.url, &state.base_url)?;
let link = match req.custom_code {
Some(custom) => {
code::validate_custom(&custom).map_err(AppError::Validation)?;
store::insert(&state.pool, &custom, &target)
.await?
.ok_or_else(|| {
AppError::Conflict(format!("the code '{custom}' is already taken"))
})?
}
None => insert_with_random_code(&state.pool, &target).await?,
};
tracing::info!(code = %link.code, target = %link.target_url, "link created");
let location = format!("/api/links/{}", link.code);
Ok((
StatusCode::CREATED,
[(header::LOCATION, location)],
Json(LinkResponse::new(link, &state.base_url)),
))
}
Two details are worth a closer look:
Result<Json<CreateLink>, JsonRejection>instead of plainJson<CreateLink>. By default axum answers bad JSON with a plain-text error. Taking theResultourselves lets?route it throughAppError, so even broken JSON gets a JSON reply.- The tuple return. axum turns
(StatusCode, headers, body)into a full response. We send201 Createdwith aLocationheader pointing at the new link's stats.
For random codes, we retry a few times in the rare case of a collision:
/// Random codes almost never collide, but "almost" isn't "never",
/// so we retry a few times before giving up.
async fn insert_with_random_code(pool: &PgPool, target: &str) -> Result<Link, AppError> {
for _ in 0..MAX_CODE_ATTEMPTS {
let candidate = code::generate();
if let Some(link) = store::insert(pool, &candidate, target).await? {
return Ok(link);
}
tracing::warn!(code = %candidate, "short code collision, retrying");
}
Err(AppError::CodeSpaceExhausted)
}
Checking the URL
Never trust input. validate_target accepts only absolute http and https URLs, up to 2,048 characters. That keeps out javascript: links, which would turn your shortener into a tool for attacks:
/// Accept only absolute http(s) URLs that don't point back at us.
fn validate_target(raw: &str, base_url: &Url) -> Result<String, AppError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(AppError::Validation("url must not be empty".into()));
}
if raw.len() > MAX_URL_LEN {
return Err(AppError::Validation(format!(
"url must be at most {MAX_URL_LEN} characters"
)));
}
let url = Url::parse(raw)
.map_err(|e| AppError::Validation(format!("that doesn't look like a valid URL ({e})")))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(AppError::Validation(
"only http and https URLs can be shortened".into(),
));
}
// A short link to another short link on this service is a redirect loop
// waiting to happen.
if url.host_str().is_some() && url.host_str() == base_url.host_str() {
return Err(AppError::Validation(
"that URL already points at this shortener".into(),
));
}
// Store the normalised form (lowercased host, percent-encoding fixed up).
Ok(url.into())
}
It also refuses URLs that point back at the shortener itself, since a short link to a short link can loop forever. And it stores the URL the url crate gives back, so https://Example.COM/a b is saved as https://example.com/a%20b.
The redirect
Thanks to record_click, the hottest handler in the service is also one of the shortest:
/// `GET /{code}`: the hot path. One query finds the link and counts the click.
pub async fn redirect(
State(state): State<AppState>,
Path(code): Path<String>,
) -> Result<Redirect, AppError> {
let target = store::record_click(&state.pool, &code)
.await?
.ok_or(AppError::NotFound)?;
// 307, not 301: browsers cache permanent redirects forever,
// and we'd never see (or count) the repeat visits.
Ok(Redirect::temporary(&target))
}
The status code is a real decision. A 301 Moved Permanently tells browsers to remember the redirect and skip us next time. That's slightly faster for visitors, but repeat visits never reach the server, so they're never counted. A 307 Temporary Redirect means every click comes through us. If you don't need click stats, 301 is a fine choice.
Stats and health
GET /api/links/{code} loads the row with store::find and returns it as JSON. GET /health runs SELECT 1, so it reports whether the service can actually reach its database, not just whether the process is alive. If Postgres is down, it answers 503 so a load balancer knows to stop sending traffic.
Wiring it all together
State and the router
Handlers need two things: the connection pool and the public base URL used to build short_url. They share them through AppState:
// src/lib.rs
/// Shared state handed to every handler. Cloning is cheap: `PgPool` is
/// already an `Arc` inside, and we wrap the base URL in one too.
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub base_url: Arc<Url>,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(routes::health))
.route("/api/links", post(routes::create_link))
.route("/api/links/{code}", get(routes::link_stats))
.route("/{code}", get(routes::redirect))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
A few axum 0.8 notes:
- Path parameters use
{code}. Older versions used:code, and you'll still see that in many tutorials. /healthand/{code}don't fight. axum's router always prefers an exact path over a parameter, so/healthwins. That's why we reservedhealthas a custom code earlier.TraceLayerwraps every request in a log span with its method, path, status and latency.
The router lives in lib.rs, not main.rs, so our tests can build the exact same app.
main.rs
The entry point reads the config, connects to Postgres, applies migrations and starts serving:
// src/main.rs
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// A missing .env file is fine; real deployments set real env vars.
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "linkshort=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
let pool = PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(5))
.connect(&config.database_url)
.await?;
// Migrations are embedded in the binary at compile time.
sqlx::migrate!().run(&pool).await?;
tracing::info!("database migrations are up to date");
let app = router(AppState::new(pool.clone(), config.base_url.clone()));
let listener = TcpListener::bind(config.bind_addr).await?;
tracing::info!("listening on http://{}", listener.local_addr()?);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
pool.close().await;
tracing::info!("shut down cleanly");
Ok(())
}
#[tokio::main] starts the Tokio runtime and runs our async main on it. Everything after that is async, and Tokio spreads incoming requests across all your CPU cores.
sqlx::migrate!() is a nice trick. At compile time it reads the migrations/ folder and bakes the SQL into the binary. At startup it applies anything new and records it in a _sqlx_migrations table. You deploy one file, and the schema can never fall behind the code.
The pool settings are worth tuning for your setup. max_connections(10) caps how many connections we hold open, and acquire_timeout makes a request fail after 5 seconds instead of hanging if they're all busy.
Config::from_env() in src/config.rs reads DATABASE_URL (required), BIND_ADDR and BASE_URL, and fails at startup with a clear message if one is malformed. Better to crash on boot than at the first request.
Graceful shutdown
When Docker or Kubernetes stops your service, they send SIGTERM. Without handling it, requests in flight get cut off mid-response. With with_graceful_shutdown, the server stops accepting new connections and lets the current ones finish:
/// Resolve on Ctrl+C or SIGTERM (what Docker and Kubernetes send),
/// so in-flight requests get to finish before we exit.
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received, finishing in-flight requests");
}
tokio::select! waits on both signals and continues with whichever comes first. On Windows there's no SIGTERM, so we swap in a future that never finishes and rely on Ctrl+C.
Testing against a real database
This might be my favourite part. SQLx's #[sqlx::test] attribute gives every test its own brand-new database, applies your migrations, hands you a pool, and drops the database when the test ends. No mocks, no shared state, and tests still run in parallel.
To send requests, we don't even start a server. An axum Router is a tower::Service, so oneshot pushes a request straight through it in memory:
// tests/api.rs
fn app(pool: PgPool) -> Router {
router(AppState::new(pool, "https://sho.rt".parse().unwrap()))
}
#[sqlx::test]
async fn create_redirect_and_count_clicks(pool: PgPool) {
let app = app(pool);
let (status, headers, body) = send(
&app,
post_json(
"/api/links",
json!({ "url": "https://www.rust-lang.org/learn" }),
),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let code = body["code"].as_str().unwrap().to_string();
assert_eq!(code.len(), 7);
assert_eq!(body["short_url"], format!("https://sho.rt/{code}"));
assert_eq!(body["clicks"], 0);
assert_eq!(headers[header::LOCATION], format!("/api/links/{code}"));
for _ in 0..3 {
let (status, headers, _) = send(&app, get(&format!("/{code}"))).await;
assert_eq!(status, StatusCode::TEMPORARY_REDIRECT);
assert_eq!(headers[header::LOCATION], "https://www.rust-lang.org/learn");
}
let (status, _, stats) = send(&app, get(&format!("/api/links/{code}"))).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(stats["clicks"], 3);
assert!(stats["last_clicked_at"].is_string());
}
(send, post_json and get are small helpers in the test file that build requests and collect the response body as JSON.)
Remember the promise about concurrent clicks? Here's the proof. Fifty clicks are fired at once on separate Tokio tasks, and all fifty get counted:
#[sqlx::test]
async fn concurrent_clicks_are_all_counted(pool: PgPool) {
let app = app(pool);
let (_, _, body) = send(
&app,
post_json("/api/links", json!({ "url": "https://docs.rs" })),
)
.await;
let code = body["code"].as_str().unwrap().to_string();
let mut tasks = tokio::task::JoinSet::new();
for _ in 0..50 {
let app = app.clone();
let uri = format!("/{code}");
tasks.spawn(async move { app.oneshot(get(&uri)).await.unwrap().status() });
}
while let Some(status) = tasks.join_next().await {
assert_eq!(status.unwrap(), StatusCode::TEMPORARY_REDIRECT);
}
let (_, _, stats) = send(&app, get(&format!("/api/links/{code}"))).await;
assert_eq!(stats["clicks"], 50);
}
The full suite also covers custom-code conflicts, every kind of bad input, unknown codes and the health check. Plus there are unit tests for code generation and URL validation that don't need a database at all. Run everything with:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres cargo test
running 5 tests
test code::tests::alphabet_skips_lookalikes ... ok
test code::tests::custom_code_rules ... ok
test routes::tests::accepts_and_normalises_http_urls ... ok
test routes::tests::rejects_bad_urls ... ok
test code::tests::generated_codes_have_the_right_shape ... ok
running 6 tests
test create_redirect_and_count_clicks ... ok
test concurrent_clicks_are_all_counted ... ok
test health_check_touches_the_database ... ok
test custom_codes_are_first_come_first_served ... ok
test invalid_input_gets_a_helpful_error ... ok
test unknown_codes_are_404 ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.76s
Eleven tests, under a second. The database user needs permission to create databases, which the default postgres user has.
Taking it for a spin
Start it up with cargo run and grab a terminal. Every output below comes from a real run, lightly trimmed.
Shorten a link:
curl -i -X POST localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"url": "https://docs.rs/axum/latest/axum/"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/links/pLZpLr6
{"code":"pLZpLr6","short_url":"http://localhost:3000/pLZpLr6","target_url":"https://docs.rs/axum/latest/axum/","clicks":0,"created_at":"2026-10-21T14:17:57.365347Z","last_clicked_at":null}
Pick your own code:
curl -X POST localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"url": "https://tokio.rs", "custom_code": "tokio"}'
{"code":"tokio","short_url":"http://localhost:3000/tokio","target_url":"https://tokio.rs/","clicks":0,...}
Follow it:
curl -i localhost:3000/pLZpLr6
HTTP/1.1 307 Temporary Redirect
location: https://docs.rs/axum/latest/axum/
content-length: 0
Check the stats after two clicks:
curl localhost:3000/api/links/pLZpLr6
{"code":"pLZpLr6","short_url":"http://localhost:3000/pLZpLr6","target_url":"https://docs.rs/axum/latest/axum/","clicks":2,"created_at":"2026-10-21T14:17:57.365347Z","last_clicked_at":"2026-10-21T14:17:57.402043Z"}
And try something naughty:
curl -X POST localhost:3000/api/links \
-H 'content-type: application/json' \
-d '{"url": "ftp://nope"}'
{"error":"only http and https URLs can be shortened"}
Press Ctrl+C (or send SIGTERM) and the logs show a clean exit:
INFO linkshort: shutdown signal received, finishing in-flight requests
INFO linkshort: shut down cleanly
Where to go next
We now have a small service that does a lot right: input is validated twice (in Rust and in Postgres), clicks are counted atomically, errors are consistent JSON, and shutdown is graceful. Not bad for an afternoon.
If you want to keep going, here are some fun next steps:
- Authentication. Anyone can create links right now. Add an API key header checked by a small axum middleware, then add a
DELETEendpoint that only the owner can call. - Rate limiting. Public shorteners attract spam. Limit link creation per IP address.
- Expiring links. Add an
expires_atcolumn and aWHERE expires_at IS NULL OR expires_at > now()to the redirect query. - Caching. Redirects are read-heavy. An in-memory cache in front of
record_clickwould cut database reads, but you'd need to count clicks another way, for example by batching them. - Richer analytics. A
clickstable with a timestamp, referrer and country per click opens the door to charts. - Ship it. Build a release binary with
cargo build --release. It's a single file with the migrations baked in, so a small multi-stage Docker image is all you need.
The nicest thing about this stack is how far the type system carries you. Once it compiles, the remaining bugs tend to be real logic questions, not typos or forgotten error cases. Happy hacking!
0 comments