Creating a Simple Rust URL Shortener: APIs and Backend Logic
Written on
Chapter 1: Introduction
Welcome to the second installment of our series on developing a straightforward URL shortener with Rust. In the first part, we established our project framework and configured the essential components to run a Docker container with a PostgreSQL database. We also installed SQLx to facilitate our initial migration and implemented basic Rust code to interact with the database. If you missed the first part, you can find it [here](link_to_part1).
What's Next?
In this session, we will focus on building the backend APIs required to operate our application. For this basic tutorial, we will create two main routes: one for the POST method and another for the GET method. We will utilize Axum to define HTTP routes, along with the crates rand and url that we installed in the previous tutorial.
The POST Method
Our first route will handle POST requests to create a shortened URL based on the original link provided by the user. The implementation will involve the following steps:
- Define the Axum route with an extractor to retrieve the user's URL from the request body.
- Use the url crate to validate the user's input.
- Generate a 4-character base62 string using rand.
- Check the database to ensure the generated string is unique. If it exists, we will attempt to generate another string.
- Store the new record in the database, using the random string as the primary key and the original URL as the secondary value.
- Return the shortened URL, which will be structured as 'our_website_url' + '/random_string'.
Given that our website URL remains constant, we only need to store the random portion of the shortened URL in the database. By keeping the random string to 4 characters, we provide over 12 million possible combinations—more than enough to minimize conflicts while still keeping the length manageable. However, for a production-level application, consider increasing the character count for added uniqueness.
The GET Method
The GET method will be simpler. Here, we will extract the 4-character string from the incoming request and query our database to find the corresponding original URL. If a match is found, the user will be redirected to the original long URL.
Hands-On Implementation
We will begin by creating an http.rs file within the src directory and a folder named http containing three files:
http
├── handler.rs
├── model.rs
└── service.rs
To maintain concise file sizes, we will distribute our backend logic into three components:
- Model: This will define a struct representing our database table.
- Handler: This component, also known as the controller, will contain functions to manage incoming requests.
- Service: This will execute the business logic and interact with the database.
Model
In model.rs, we will define the following struct:
use serde::{Deserialize, Serialize};
/// Struct representing the URL database table: id and long_url.
#[derive(Serialize, Deserialize)]
pub struct Url {
/// The random short URL string
pub id: String,
/// The long URL corresponding to the short URL.
pub long_url: String,
}
This struct serves as a representation for our database table, allowing SQLx to map values to and from PostgreSQL. While encapsulation through getter and setter methods is a best practice for production code, we will keep the fields public for simplicity in this tutorial.
Service
In service.rs, we will implement the following functions:
use anyhow::Result;
use rand::{distributions::Alphanumeric, Rng};
use sqlx::{Error, Pool, Postgres};
use super::model::Url;
const MAX_RETRIES: usize = 5;
const SHORTURL_LEN: usize = 4;
pub async fn resolve_short_url(db: &Pool, url: String) -> Result<String> {
// Check if the random string exists in the database
match sqlx::query_as!(
Url,
r#"SELECT * FROM url
WHERE id = $1"#,url
)
.fetch_one(db)
.await
{
Ok(data) => Ok(data.long_url), // Found URL
Err(err) => Err(err.into()), // Error occurred
}
}
pub async fn shorten_url(db: &Pool, url: String) -> Result<String> {
for _ in 0..MAX_RETRIES {
let rng = rand::thread_rng();
let random_string: String = rng
.sample_iter(&Alphanumeric)
.take(SHORTURL_LEN)
.map(char::from)
.collect();
match sqlx::query_as!(
Url,
r#"INSERT INTO url (id, long_url)
VALUES ($1, $2)
RETURNING id, long_url;"#,
random_string,
url
)
.fetch_one(db)
.await
{
Ok(_) => return Ok(random_string), // Success
Err(err) => {
// Check for unique constraint violation
if let sqlx::Error::Database(db_err) = &err {
if db_err.is_foreign_key_violation() {
continue; // Retry}
}
return Err(err); // Other errors
}
}
}
Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"Maximum retries reached without successful insertion",
)))
}
The code includes two primary functions:
- resolve_short_url: Checks for the existence of a short URL in the database. If found, it returns the original URL.
- shorten_url: Accepts a long URL, generates a random 4-character string, checks for uniqueness, and attempts to save it in the database.
Handler
Next, we will integrate our service functions into the routing logic in handler.rs:
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::{Response, StatusCode},
Form,
};
use serde::Deserialize;
use url::Url;
use super::{
service::{resolve_short_url, shorten_url},
AppState,
};
const BASE_URL: &str = "localhost:8086/"; // Host URL
#[derive(Deserialize)]
pub struct FormData {
url: String,
}
pub async fn get_url(
State(state): State<AppState>,
Path(url): Path<String>,
) -> Response {
match resolve_short_url(&state.db, url).await {
Ok(result) => Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("Location", result)
.body("Redirecting...".into())
.unwrap(),
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body("Sorry, your short URL does not exist!".into())
.unwrap(),
}
}
pub async fn post_url(
State(state): State<AppState>,
Form(form_data): Form<FormData>,
) -> Response {
if Url::parse(&form_data.url).is_ok() {
match shorten_url(&state.db, form_data.url).await {
Ok(result) => Response::builder()
.status(StatusCode::OK)
.body(format!("{}{}", BASE_URL, result))
.unwrap(),
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(err.to_string())
.unwrap(),
}
} else {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body("Not a valid URL!".into())
.unwrap()
}
}
The get_url function checks if a short URL exists and responds with a redirect or a 404 error. The post_url function validates the provided URL and attempts to shorten it, responding with the resulting short URL or an error if applicable.
Axum HTTP Backend Integration
Now, we will consolidate all components into the Axum backend in http.rs:
use std::sync::Arc;
use anyhow::Result;
use axum::{http::StatusCode, routing::get, Router};
use sqlx::{PgPool, Pool, Postgres};
mod handler;
mod model;
mod service;
/// Struct representing the application state, including a database connection pool
struct AppState {
db: Pool<Postgres>,
}
pub async fn serve(db: PgPool) -> Result<()> {
let app_state = AppState { db };
let app = api_router(Arc::new(app_state));
let port = 8086;
let address = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
axum::serve(address, app.into_make_service()).await?;
Ok(())
}
fn api_router(app_state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(home).post(handler::post_url))
.route("/:url", get(handler::get_url))
.with_state(app_state)
}
async fn home() -> Result<(), StatusCode> {
Err(StatusCode::NOT_FOUND)
}
This section outlines the application state management with a PostgreSQL pool and establishes the API routes using Axum.
Finalization in main.rs
Lastly, we will update main.rs to start our HTTP server:
use anyhow::Result;
use dotenvy::dotenv;
use dotenvy_macro::dotenv;
mod db;
mod http;
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let db_url = dotenv!("DATABASE_URL").to_owned();
let db = db::connect(db_url).await?;
Ok(())
}
Testing the Application
Now it’s time to test our backend. Ensure your Docker container is running, and execute sqlx migrate run to prepare your database. Start the Rust project with cargo run.
To shorten a URL, create a POST request to localhost:8086, using a form-encoded body with the key url. Upon submission, you will receive a response with the shortened URL.
Next, test the shortened URL in your browser. If valid, you will be redirected to the original website; otherwise, an error message will appear.
Conclusion
We have successfully built the backend logic for our URL shortener, allowing users to create short URLs and redirect seamlessly. In the next part, we will develop a simple frontend to interact with our URL shortener without relying on external tools.
For the complete code, visit my GitHub repository, where you can also find a modified Docker Compose YAML file that creates a volume for easier migration management.
Your feedback is greatly appreciated! Feel free to reach out or open an issue on GitHub. Don't forget to show your support by clapping or following me for future updates. Thank you for being a part of this journey!
This video covers the continuation of our Rust URL shortener project, where we implement backend APIs to handle URL shortening.
In this beginner-friendly tutorial, learn how to create your own link shortener using Rust.