This is the final tutorial in our Rust series. We bring together everything you learned — ownership, error handling, async, web APIs, databases, and CLI tools — into one complete project.
We will build LinkShort, a URL shortener. It has two parts:
- A REST API — create, list, and redirect short links (Axum + SQLx)
- A CLI tool — manage links from the terminal (Clap + Reqwest)
By the end, you will have a working application that you can run locally, extend, and deploy.
Project Overview
Here is what each crate does in our project:
- clap — parses command-line arguments for the CLI tool
- serde — serializes and deserializes JSON data
- reqwest — makes HTTP requests from the CLI to the API
- axum — runs the web server and handles routes
- sqlx — stores and retrieves links from a SQLite database
- tokio — powers async runtime for both the API and CLI
The project uses a Cargo workspace with two binaries: linkshort-api and linkshort-cli. They share a common library crate for data types.
Setting Up the Workspace
Create the project structure:
cargo new linkshort
cd linkshort
Replace Cargo.toml with a workspace:
[workspace]
members = ["shared", "api", "cli"]
resolver = "2"
Create the three crates:
cargo new shared --lib
cargo new api
cargo new cli
Shared Library
The shared crate defines data types used by both the API and CLI. Edit shared/Cargo.toml:
[package]
name = "linkshort-shared"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
Create the shared types in shared/src/lib.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Link {
pub id: i64,
pub short_code: String,
pub target_url: String,
pub clicks: i64,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateLink {
pub target_url: String,
pub short_code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LinkStats {
pub total_links: i64,
pub total_clicks: i64,
}
These types use serde for JSON serialization. Both the API and CLI import them. This avoids duplicating struct definitions.
Building the API
Edit api/Cargo.toml:
[package]
name = "linkshort-api"
version = "0.1.0"
edition = "2024"
[dependencies]
linkshort-shared = { path = "../shared" }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9"
Database Setup
We use SQLite so there is no server to install. Create api/src/db.rs:
use sqlx::sqlite::SqlitePool;
pub async fn create_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite:linkshort.db?mode=rwc")
.await
.expect("Failed to connect to database");
sqlx::query(
"CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_code TEXT UNIQUE NOT NULL,
target_url TEXT NOT NULL,
clicks INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)"
)
.execute(&pool)
.await
.expect("Failed to create table");
pool
}
The ?mode=rwc flag creates the database file if it does not exist. The CREATE TABLE IF NOT EXISTS query is safe to run every time the server starts.
API Handlers
Create api/src/handlers.rs:
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Redirect},
Json,
};
use linkshort_shared::{CreateLink, Link, LinkStats};
use rand::Rng;
use sqlx::sqlite::SqlitePool;
// Generate a random 6-character code
fn generate_code() -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::rng();
(0..6)
.map(|_| {
let idx = rng.random_range(0..chars.len());
chars.as_bytes()[idx] as char
})
.collect()
}
// POST /api/links — create a new short link
pub async fn create_link(
State(pool): State<SqlitePool>,
Json(input): Json<CreateLink>,
) -> Result<(StatusCode, Json<Link>), (StatusCode, String)> {
let code = input.short_code.unwrap_or_else(generate_code);
let result = sqlx::query_as!(
Link,
"INSERT INTO links (short_code, target_url)
VALUES (?, ?)
RETURNING id, short_code, target_url, clicks, created_at",
code,
input.target_url
)
.fetch_one(&pool)
.await;
match result {
Ok(link) => Ok((StatusCode::CREATED, Json(link))),
Err(e) => {
let msg = format!("Failed to create link: {e}");
Err((StatusCode::BAD_REQUEST, msg))
}
}
}
// GET /api/links — list all links
pub async fn list_links(
State(pool): State<SqlitePool>,
) -> Result<Json<Vec<Link>>, (StatusCode, String)> {
let links = sqlx::query_as!(
Link,
"SELECT id, short_code, target_url, clicks, created_at
FROM links ORDER BY created_at DESC"
)
.fetch_all(&pool)
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {e}"))
})?;
Ok(Json(links))
}
// GET /api/stats — get total links and clicks
pub async fn get_stats(
State(pool): State<SqlitePool>,
) -> Result<Json<LinkStats>, (StatusCode, String)> {
let stats = sqlx::query_as!(
LinkStats,
"SELECT COUNT(*) as total_links,
COALESCE(SUM(clicks), 0) as total_clicks
FROM links"
)
.fetch_one(&pool)
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {e}"))
})?;
Ok(Json(stats))
}
// DELETE /api/links/:code — delete a link
pub async fn delete_link(
State(pool): State<SqlitePool>,
Path(code): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let result = sqlx::query!(
"DELETE FROM links WHERE short_code = ?",
code
)
.execute(&pool)
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Delete failed: {e}"))
})?;
if result.rows_affected() == 0 {
Err((StatusCode::NOT_FOUND, "Link not found".to_string()))
} else {
Ok(StatusCode::NO_CONTENT)
}
}
// GET /:code — redirect to the target URL
pub async fn redirect_link(
State(pool): State<SqlitePool>,
Path(code): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Increment click count
let link = sqlx::query_as!(
Link,
"UPDATE links SET clicks = clicks + 1
WHERE short_code = ?
RETURNING id, short_code, target_url, clicks, created_at",
code
)
.fetch_optional(&pool)
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {e}"))
})?;
match link {
Some(link) => Ok(Redirect::temporary(&link.target_url)),
None => Err((StatusCode::NOT_FOUND, "Link not found".to_string())),
}
}
Notice how each handler returns Result. The error type is a tuple of StatusCode and String. Axum converts this into a proper HTTP response automatically.
The redirect_link handler updates the click count and redirects in a single query using RETURNING. This is an atomic operation — no race conditions.
Putting It Together
Create api/src/main.rs:
mod db;
mod handlers;
use axum::{routing::{delete, get, post}, Router};
use tower_http::cors::CorsLayer;
#[tokio::main]
async fn main() {
let pool = db::create_pool().await;
let app = Router::new()
// API routes
.route("/api/links", post(handlers::create_link))
.route("/api/links", get(handlers::list_links))
.route("/api/links/{code}", delete(handlers::delete_link))
.route("/api/stats", get(handlers::get_stats))
// Redirect route
.route("/{code}", get(handlers::redirect_link))
.layer(CorsLayer::permissive())
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Failed to bind to port 3000");
println!("Server running on http://localhost:3000");
axum::serve(listener, app)
.await
.expect("Server failed");
}
The server listens on port 3000. API routes are under /api/ and the redirect lives at the root. This is a common pattern for URL shorteners.
Run the API:
cargo run --bin linkshort-api
Test it with curl:
# Create a link
curl -X POST http://localhost:3000/api/links \
-H "Content-Type: application/json" \
-d '{"target_url": "https://www.rust-lang.org"}'
# List all links
curl http://localhost:3000/api/links
# Get stats
curl http://localhost:3000/api/stats
Building the CLI
The CLI talks to the API over HTTP. This means you can run the CLI from any machine that can reach the server. Edit cli/Cargo.toml:
[package]
name = "linkshort-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
linkshort-shared = { path = "../shared" }
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
CLI Arguments
Create cli/src/main.rs:
use clap::{Parser, Subcommand};
use linkshort_shared::{CreateLink, Link, LinkStats};
#[derive(Parser)]
#[command(name = "linkshort")]
#[command(about = "A URL shortener CLI")]
struct Cli {
/// API server URL
#[arg(long, default_value = "http://localhost:3000")]
server: String,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Create a new short link
Create {
/// The target URL to shorten
url: String,
/// Custom short code (optional)
#[arg(short, long)]
code: Option<String>,
},
/// List all links
List,
/// Show statistics
Stats,
/// Delete a link by its short code
Delete {
/// The short code to delete
code: String,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let client = reqwest::Client::new();
let result = match cli.command {
Commands::Create { url, code } => {
create_link(&client, &cli.server, &url, code).await
}
Commands::List => list_links(&client, &cli.server).await,
Commands::Stats => show_stats(&client, &cli.server).await,
Commands::Delete { code } => {
delete_link(&client, &cli.server, &code).await
}
};
if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
async fn create_link(
client: &reqwest::Client,
server: &str,
url: &str,
code: Option<String>,
) -> Result<(), String> {
let body = CreateLink {
target_url: url.to_string(),
short_code: code,
};
let response = client
.post(format!("{server}/api/links"))
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(format!("Server error: {text}"));
}
let link: Link = response
.json()
.await
.map_err(|e| format!("Invalid response: {e}"))?;
println!("Created: {server}/{}", link.short_code);
println!("Target: {}", link.target_url);
Ok(())
}
async fn list_links(
client: &reqwest::Client,
server: &str,
) -> Result<(), String> {
let response = client
.get(format!("{server}/api/links"))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let links: Vec<Link> = response
.json()
.await
.map_err(|e| format!("Invalid response: {e}"))?;
if links.is_empty() {
println!("No links yet. Create one with: linkshort create <url>");
return Ok(());
}
println!("{:<10} {:<8} {}", "CODE", "CLICKS", "TARGET");
println!("{}", "-".repeat(60));
for link in links {
println!(
"{:<10} {:<8} {}",
link.short_code, link.clicks, link.target_url
);
}
Ok(())
}
async fn show_stats(
client: &reqwest::Client,
server: &str,
) -> Result<(), String> {
let response = client
.get(format!("{server}/api/stats"))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let stats: LinkStats = response
.json()
.await
.map_err(|e| format!("Invalid response: {e}"))?;
println!("Total links: {}", stats.total_links);
println!("Total clicks: {}", stats.total_clicks);
Ok(())
}
async fn delete_link(
client: &reqwest::Client,
server: &str,
code: &str,
) -> Result<(), String> {
let response = client
.delete(format!("{server}/api/links/{code}"))
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
if response.status().is_success() {
println!("Deleted: {code}");
Ok(())
} else {
let text = response.text().await.unwrap_or_default();
Err(format!("Failed to delete: {text}"))
}
}
The CLI uses Clap’s derive macro. Each subcommand maps to a function that calls the API with reqwest. Error handling uses Result<(), String> for simplicity.
Using the CLI
Start the API server in one terminal:
cargo run --bin linkshort-api
In another terminal, use the CLI:
# Create a link
cargo run --bin linkshort-cli -- create https://www.rust-lang.org
# Create with custom code
cargo run --bin linkshort-cli -- create https://doc.rust-lang.org --code docs
# List all links
cargo run --bin linkshort-cli -- list
# Show stats
cargo run --bin linkshort-cli -- stats
# Delete a link
cargo run --bin linkshort-cli -- delete docs
After building, you can install both binaries:
cargo install --path api
cargo install --path cli
Now you can run linkshort-cli create https://example.com from anywhere.
How the Pieces Connect
Let us trace what happens when you run linkshort-cli create https://example.com:
- Clap parses the arguments into the
Commands::Createvariant - Reqwest sends a POST request with a JSON body (serialized by serde)
- Axum receives the request, extracts the JSON with
Json<CreateLink> - The handler generates a random code and inserts it into SQLx (SQLite)
- SQLx returns the new row, Axum wraps it in
Json<Link>(serialized by serde) - Reqwest receives the response, deserializes it into a
Linkstruct - The CLI prints the result
Every crate plays a specific role. Serde is the glue — it handles serialization on both sides.
Adding URL Validation
Right now, you can create a link to any string. Let us validate that the target URL is reachable. Add this to the API’s create_link handler:
// Validate the URL by sending a HEAD request
async fn validate_url(url: &str) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Client error: {e}"))?;
let response = client
.head(url)
.send()
.await
.map_err(|_| format!("URL is not reachable: {url}"))?;
if response.status().is_success()
|| response.status().is_redirection()
{
Ok(())
} else {
Err(format!(
"URL returned status {}: {url}",
response.status()
))
}
}
Add reqwest to the API’s dependencies:
reqwest = { version = "0.12", features = ["json"] }
Call validate_url at the start of create_link:
pub async fn create_link(
State(pool): State<SqlitePool>,
Json(input): Json<CreateLink>,
) -> Result<(StatusCode, Json<Link>), (StatusCode, String)> {
// Validate URL first
validate_url(&input.target_url)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let code = input.short_code.unwrap_or_else(generate_code);
// ... rest of the handler
}
Now the API rejects invalid URLs before storing them. The HEAD request is lightweight — it only fetches headers, not the full page.
Error Handling Patterns
This project uses several error handling patterns from earlier tutorials:
Pattern 1: Result with tuple errors — Axum handlers return Result<T, (StatusCode, String)>. This is simple and works well for small projects.
// The ? operator converts sqlx errors into our tuple type
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Pattern 2: Early returns with ? — Each CLI function returns Result<(), String>. If any step fails, the error propagates up to main.
Pattern 3: Option to Result — The redirect handler uses fetch_optional which returns Option<Link>. We convert None to a 404 error.
match link {
Some(link) => Ok(Redirect::temporary(&link.target_url)),
None => Err((StatusCode::NOT_FOUND, "Link not found".to_string())),
}
For a larger project, you would define a custom error type with thiserror and implement IntoResponse. But for this size, tuples are clear enough.
Testing the API
Add tests in api/src/main.rs or a separate test file. Here is an integration test:
#[cfg(test)]
mod tests {
use axum::http::StatusCode;
use axum_test::TestServer;
use linkshort_shared::CreateLink;
use super::*;
async fn setup() -> TestServer {
let pool = db::create_pool().await;
let app = Router::new()
.route("/api/links", post(handlers::create_link))
.route("/api/links", get(handlers::list_links))
.route("/api/stats", get(handlers::get_stats))
.with_state(pool);
TestServer::new(app).unwrap()
}
#[tokio::test]
async fn test_create_and_list() {
let server = setup().await;
// Create a link
let body = CreateLink {
target_url: "https://example.com".to_string(),
short_code: Some("test1".to_string()),
};
let response = server.post("/api/links").json(&body).await;
assert_eq!(response.status_code(), StatusCode::CREATED);
// List links
let response = server.get("/api/links").await;
assert_eq!(response.status_code(), StatusCode::OK);
let links: Vec<linkshort_shared::Link> = response.json();
assert!(!links.is_empty());
assert_eq!(links[0].short_code, "test1");
}
}
Add axum-test to your dev dependencies:
[dev-dependencies]
axum-test = "16"
Run the tests:
cargo test --bin linkshort-api
Project Structure Summary
Here is the final project layout:
linkshort/
├── Cargo.toml # Workspace definition
├── shared/
│ ├── Cargo.toml
│ └── src/lib.rs # Link, CreateLink, LinkStats types
├── api/
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # Router setup and server
│ ├── db.rs # Database pool and migrations
│ └── handlers.rs # Create, list, delete, redirect, stats
├── cli/
│ ├── Cargo.toml
│ └── src/main.rs # Clap parser and reqwest calls
└── linkshort.db # SQLite database (created at runtime)
This structure scales well. You can add more crates to the workspace — for example, a worker crate that processes background jobs, or a web crate that serves an HTML frontend.
What You Practiced
This project used concepts from across the entire series:
- Ownership and borrowing — passing references to handlers and functions
- Error handling —
Result,?operator,map_err - Structs and enums —
Link,CreateLink,Commands - Traits —
Serialize,Deserialize,IntoResponse,Parser - Async/await — all handlers and CLI functions are async
- Modules —
db.rs,handlers.rs, workspace crates - Generics —
Json<T>,State<T>,Result<T, E> - Closures —
.map_err(|e| ...),.map(|_| ...)
Where to Go From Here
You finished the Rust tutorial series. Here are some ways to keep building:
Extend this project:
- Add user authentication with JWT tokens
- Add rate limiting with Tower middleware
- Add a web frontend with Leptos or Yew
- Deploy to a VPS with a systemd service
- Add a hit counter dashboard with charts
Explore the ecosystem:
- Shuttle — deploy Rust backends with zero config
- Leptos — build full-stack web apps in Rust
- Tauri — build desktop apps with Rust
- Embassy — async embedded Rust
Read more:
- The Rust Book — the official reference
- Rust by Example — learn through examples
- Awesome Rust — curated list of crates