├── .gitignore ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config.rs ├── db.rs ├── docker-compose.yml ├── error.rs ├── handlers.rs ├── lib.rs ├── loggers.rs ├── main.rs ├── migrations ├── 20230310123456_create_urls │ ├── down.sql │ └── up.sql ├── 20230310123567_create_usage_logs │ ├── down.sql │ └── up.sql ├── 20230310123678_create_redirect_stats │ ├── down.sql │ └── up.sql └── 20230310123789_add_expiration_date_to_urls │ ├── down.sql │ └── up.sql ├── models.rs ├── routes.rs ├── schema.rs ├── tests └── integrationTests.rs └── utils.rs /.gitignore: -------------------------------------------------------------------------------- 1 | ### Example user template template 2 | ### Example user template 3 | 4 | # IntelliJ project files 5 | .idea 6 | *.iml 7 | out 8 | gen 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-url-shortener" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | actix-web = "4" 8 | actix-rt = "2" 9 | diesel = { version = "2.0.4", features = ["sqlite"] } 10 | dotenvy = "0.15" 11 | serde = { version = "1.0", features = ["derive"] } 12 | serde_json = "1.0" 13 | uuid = { version = "1", features = ["v4"] } 14 | chrono = { version = "0.4", features = ["serde"] } 15 | rand = "0.8" 16 | lazy_static = "1.4" 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ========================================= 2 | # Stage 1 - Build the application 3 | # ========================================= 4 | FROM rust:1.69 as builder 5 | 6 | # Create a new empty shell project 7 | RUN USER=root cargo new --bin rust-url-shortener 8 | WORKDIR /rust-url-shortener 9 | 10 | # Copy Cargo files 11 | COPY Cargo.toml Cargo.lock ./ 12 | 13 | # Copy the source code 14 | COPY . . 15 | 16 | # Install system dependencies needed by Diesel CLI, if you plan to run migrations inside the container 17 | RUN apt-get update && apt-get install -y libsqlite3-dev 18 | 19 | # Build for release 20 | RUN cargo build --release 21 | 22 | # ========================================= 23 | # Stage 2 - Create a minimal runtime image 24 | # ========================================= 25 | FROM debian:bullseye-slim 26 | 27 | # Install SQLite if you need to run migrations or use local sqlite 28 | RUN apt-get update && apt-get install -y sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/* 29 | 30 | # Copy the compiled binary from builder 31 | COPY --from=builder /rust-url-shortener/target/release/rust-url-shortener /usr/local/bin/rust-url-shortener 32 | 33 | # Create a non-root user 34 | RUN useradd -m -s /bin/bash appuser 35 | USER appuser 36 | 37 | # Expose the port your application runs on (8080 by default) 38 | EXPOSE 8080 39 | 40 | ENV DATABASE_URL=rust_url_shortener.db 41 | ENV BASE_URL=http://localhost:8080 42 | 43 | CMD ["rust-url-shortener"] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Son Nguyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Rust URL Shortener 2 | 3 | PROJECT_NAME = rust-url-shortener 4 | DOCKER_IMAGE = rust-url-shortener 5 | 6 | # Default target 7 | all: build 8 | 9 | # Build the project in debug mode 10 | build: 11 | cargo build 12 | 13 | # Build the project in release mode 14 | release: 15 | cargo build --release 16 | 17 | # Run the application locally (debug mode) 18 | run: 19 | cargo run 20 | 21 | # Run tests 22 | test: 23 | cargo test 24 | 25 | # Format code 26 | fmt: 27 | cargo fmt 28 | 29 | # Check for lint 30 | lint: 31 | cargo clippy -- -D warnings 32 | 33 | # Docker build 34 | docker-build: 35 | docker build -t $(DOCKER_IMAGE) . 36 | 37 | # Docker run (detached) 38 | docker-run: 39 | docker run -d -p 8080:8080 --name $(DOCKER_IMAGE) $(DOCKER_IMAGE) 40 | 41 | # Docker stop and remove container 42 | docker-stop: 43 | docker stop $(DOCKER_IMAGE) || true 44 | docker rm $(DOCKER_IMAGE) || true 45 | 46 | # Docker compose 47 | compose-up: 48 | docker-compose up -d 49 | 50 | compose-down: 51 | docker-compose down 52 | 53 | # Clean up 54 | clean: 55 | cargo clean 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust URL Shortener Service 2 | 3 | [![Rust Version](https://img.shields.io/badge/Rust-1.56+-orange.svg)](https://www.rust-lang.org) 4 | [![PostgreSQL](https://img.shields.io/badge/Database-PostgreSQL-blue.svg?logo=postgresql&logoColor=white)](#) 5 | [![MySQL](https://img.shields.io/badge/Database-MySQL-4479A1.svg?logo=mysql&logoColor=white)](#) 6 | [![SQLite](https://img.shields.io/badge/Database-SQLite-lightgrey.svg?logo=sqlite&logoColor=blue)](#) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | 9 | A full‑stack URL shortening service built entirely in **Rust** using Actix‑web and Diesel with SQLite. This service allows users to: 10 | 11 | - **Create Short URLs:** Submit a long URL and receive a unique, shortened URL. 12 | - **List URLs:** Retrieve a list of all shortened URLs. 13 | - **Redirection:** Visit a short URL to be automatically redirected to the original URL. 14 | 15 | --- 16 | 17 | ## Why Use Rust? 18 | 19 | - **Performance:** Rust is known for its high performance and low overhead, making it ideal for building fast, scalable web services. 20 | - **Memory Safety:** Rust’s ownership system guarantees memory safety without needing a garbage collector, which minimizes runtime errors. 21 | - **Concurrency:** Rust provides powerful concurrency primitives that help in building robust, multi-threaded applications. 22 | - **Modern Tooling:** With a growing ecosystem, Rust offers excellent tooling (cargo, rustfmt, clippy) for building and maintaining reliable software. 23 | - **Reliability:** Rust’s strict compile-time checks catch many bugs early in development, ensuring a more reliable codebase. 24 | 25 | --- 26 | 27 | ## Technology Stack 28 | 29 | - **Web Framework:** [Actix‑web](https://actix.rs/) 30 | - **ORM & Database:** [Diesel](https://diesel.rs/) with SQLite 31 | - **Environment Management:** dotenvy for environment variables 32 | - **Utilities:** rand for generating short codes, chrono for timestamps 33 | 34 | --- 35 | 36 | ## File Structure 37 | 38 | ``` 39 | rust-url-shortener/ 40 | ├── Cargo.toml 41 | ├── .env 42 | ├── migrations/ 43 | │ └── 20230310123456_create_urls/ 44 | │ ├── up.sql 45 | │ └── down.sql 46 | ├── src/ 47 | │ ├── main.rs 48 | │ ├── db.rs 49 | │ ├── models.rs 50 | │ ├── schema.rs 51 | │ └── routes.rs 52 | └── README.md 53 | ``` 54 | 55 | --- 56 | 57 | ## Getting Started 58 | 59 | ### Prerequisites 60 | 61 | - **Rust & Cargo:** Install via [rustup.rs](https://rustup.rs/) 62 | - **SQLite:** Ensure SQLite is installed on your system. 63 | - **Diesel CLI:** Install Diesel CLI with SQLite support by running: 64 | ```bash 65 | cargo install diesel_cli --no-default-features --features sqlite 66 | ``` 67 | 68 | ### Setup 69 | 70 | 1. **Clone the Repository or Create the Project Folder** 71 | 72 | You can create the file structure using the following terminal commands: 73 | 74 | ```bash 75 | # Create project directory and initialize a new binary project 76 | mkdir rust-url-shortener 77 | cd rust-url-shortener 78 | cargo init --bin 79 | 80 | # Create directories for Diesel migrations 81 | mkdir -p migrations/20230310123456_create_urls 82 | 83 | # Create migration SQL files 84 | touch migrations/20230310123456_create_urls/up.sql 85 | touch migrations/20230310123456_create_urls/down.sql 86 | 87 | # Create additional source files 88 | cd src 89 | touch db.rs models.rs schema.rs routes.rs 90 | cd .. 91 | ``` 92 | 93 | 2. **Create a `.env` File** 94 | 95 | In the project root, create a file named `.env` with the following content (adjust values as needed): 96 | 97 | ```env 98 | DATABASE_URL=rust_url_shortener.db 99 | BASE_URL=http://localhost:8080 100 | ``` 101 | 102 | 3. **Run Diesel Migrations** 103 | 104 | Set up the database schema by running: 105 | ```bash 106 | diesel migration run 107 | ``` 108 | 109 | --- 110 | 111 | ## Running the Application 112 | 113 | 1. **Build and Run the Server** 114 | 115 | ```bash 116 | cargo run 117 | ``` 118 | 119 | The server will start at [http://localhost:8080](http://localhost:8080). 120 | 121 | 2. **API Endpoints** 122 | 123 | - **Create URL:** 124 | `POST /` with JSON body: 125 | ```json 126 | { 127 | "original_url": "https://example.com" 128 | } 129 | ``` 130 | Returns the created URL entry with a generated short code. 131 | 132 | - **List URLs:** 133 | `GET /` returns a list of shortened URLs. 134 | 135 | - **Redirection:** 136 | `GET /{short_code}` redirects to the original URL if found. 137 | 138 | --- 139 | 140 | ## Customization 141 | 142 | - **Changing the BASE_URL:** 143 | Update the `BASE_URL` variable in your `.env` file to reflect your domain (e.g., `https://yourdomain.com`). 144 | 145 | - **Extending Functionality:** 146 | Modify or add additional endpoints in `src/routes.rs` and update models in `src/models.rs` as needed. 147 | 148 | - **Integrating a Frontend:** 149 | This example provides a robust backend API. You can build a frontend in your preferred technology to consume this API. 150 | 151 | --- 152 | 153 | ## Contributing 154 | 155 | Feel free to fork this repository and submit pull requests with improvements or customizations. For issues or feature requests, please open an issue. 156 | 157 | --- 158 | 159 | ## License 160 | 161 | This project is licensed under the MIT License. 162 | 163 | --- 164 | 165 | ## Authors 166 | 167 | The UNC-CH Google Developer Student Club (GDSC) team. 168 | -------------------------------------------------------------------------------- /config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | pub struct Config { 4 | pub database_url: String, 5 | pub base_url: String, 6 | } 7 | 8 | impl Config { 9 | /// Loads configuration from environment variables. 10 | /// Panics if DATABASE_URL is not set. 11 | pub fn from_env() -> Self { 12 | let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 13 | let base_url = env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); 14 | Config { database_url, base_url } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /db.rs: -------------------------------------------------------------------------------- 1 | use diesel::prelude::*; 2 | use diesel::r2d2::{self, ConnectionManager}; 3 | 4 | pub type DbPool = r2d2::Pool>; 5 | 6 | pub fn establish_connection_pool(database_url: &str) -> DbPool { 7 | let manager = ConnectionManager::::new(database_url); 8 | r2d2::Pool::builder() 9 | .build(manager) 10 | .expect("Failed to create pool.") 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | app: 4 | build: . 5 | container_name: rust-url-shortener 6 | ports: 7 | - "8080:8080" 8 | environment: 9 | DATABASE_URL: "rust_url_shortener.db" 10 | BASE_URL: "http://localhost:8080" 11 | volumes: 12 | # If you need persistent storage for SQLite file 13 | - ./rust_url_shortener.db:/app/rust_url_shortener.db 14 | depends_on: 15 | - db 16 | 17 | db: 18 | image: nouchka/sqlite3 # A minimal SQLite container 19 | container_name: sqlite-container 20 | volumes: 21 | - ./rust_url_shortener.db:/data/sqlite.db 22 | command: ["sqlite3", "/data/sqlite.db"] 23 | -------------------------------------------------------------------------------- /error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug)] 4 | pub enum AppError { 5 | DbError(String), 6 | NotFound(String), 7 | InvalidInput(String), 8 | InternalError(String), 9 | } 10 | 11 | impl std::error::Error for AppError {} 12 | 13 | impl fmt::Display for AppError { 14 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 15 | match self { 16 | AppError::DbError(msg) => write!(f, "Database error: {}", msg), 17 | AppError::NotFound(msg) => write!(f, "Not found: {}", msg), 18 | AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), 19 | AppError::InternalError(msg) => write!(f, "Internal error: {}", msg), 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /handlers.rs: -------------------------------------------------------------------------------- 1 | // src/handlers.rs 2 | use actix_web::{web, HttpResponse, Responder, HttpRequest}; 3 | use diesel::prelude::*; 4 | use crate::db::DbPool; 5 | use crate::models::Url; 6 | use crate::service::create_short_url; 7 | use serde::Deserialize; 8 | use std::env; 9 | 10 | #[derive(Deserialize)] 11 | pub struct CreateUrlRequest { 12 | pub original_url: String, 13 | } 14 | 15 | /// Handler for creating a shortened URL. 16 | pub async fn create_url_handler( 17 | pool: web::Data, 18 | item: web::Json, 19 | ) -> impl Responder { 20 | let mut conn = pool.get().expect("Couldn't get db connection from pool"); 21 | match web::block(move || create_short_url(&mut conn, &item.original_url)).await { 22 | Ok(url_entry) => { 23 | let base = env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); 24 | let short_url = format!("{}/{}", base, url_entry.short_code); 25 | HttpResponse::Created().json(serde_json::json!({ 26 | "original_url": url_entry.original_url, 27 | "short_code": url_entry.short_code, 28 | "short_url": short_url, 29 | "created_at": url_entry.created_at 30 | })) 31 | } 32 | Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)), 33 | } 34 | } 35 | 36 | /// Handler for listing all shortened URLs. 37 | pub async fn list_urls_handler(pool: web::Data) -> impl Responder { 38 | use crate::schema::urls::dsl::*; 39 | let mut conn = pool.get().expect("Couldn't get db connection from pool"); 40 | match web::block(move || urls.order(created_at.desc()).load::(&mut conn)).await { 41 | Ok(urls_list) => HttpResponse::Ok().json(urls_list), 42 | Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)), 43 | } 44 | } 45 | 46 | /// Handler for redirecting a short URL to its original URL. 47 | pub async fn redirect_handler( 48 | pool: web::Data, 49 | req: HttpRequest, 50 | ) -> impl Responder { 51 | let code = req.match_info().get("code").unwrap_or("").to_string(); 52 | let mut conn = pool.get().expect("Couldn't get db connection from pool"); 53 | use crate::schema::urls::dsl::*; 54 | match web::block(move || urls.filter(short_code.eq(code)).first::(&mut conn)).await { 55 | Ok(url_entry) => HttpResponse::Found() 56 | .append_header(("Location", url_entry.original_url)) 57 | .finish(), 58 | Err(_) => HttpResponse::NotFound().body("URL not found"), 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib.rs: -------------------------------------------------------------------------------- 1 | 2 | pub mod config; 3 | pub mod db; 4 | pub mod error; 5 | pub mod models; 6 | pub mod routes; 7 | pub mod schema; 8 | pub mod utils; 9 | -------------------------------------------------------------------------------- /loggers.rs: -------------------------------------------------------------------------------- 1 | pub fn init_logging() { 2 | env_logger::init(); 3 | } 4 | -------------------------------------------------------------------------------- /main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate diesel; 3 | 4 | mod db; 5 | mod models; 6 | mod routes; 7 | mod schema; 8 | 9 | use actix_web::{middleware::Logger, web, App, HttpServer}; 10 | use db::establish_connection_pool; 11 | use dotenvy::dotenv; 12 | use std::env; 13 | 14 | #[actix_web::main] 15 | async fn main() -> std::io::Result<()> { 16 | // Load environment variables from the .env file 17 | dotenv().ok(); 18 | 19 | // Initialize the logger (env_logger logs info to stdout) 20 | env_logger::init(); 21 | 22 | // Retrieve the DATABASE_URL from the environment. The application will panic if it's not set. 23 | let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 24 | 25 | // Establish a connection pool using the provided database URL 26 | let pool = establish_connection_pool(&database_url); 27 | 28 | // Define the server address to bind to (listening on port 8080) 29 | let server_address = "0.0.0.0:8080"; 30 | println!("Starting server at: {}", server_address); 31 | 32 | // Create and run the HTTP server using Actix-web 33 | HttpServer::new(move || { 34 | App::new() 35 | // Share the database pool across all application routes 36 | .app_data(web::Data::new(pool.clone())) 37 | // Use default logging middleware to log HTTP requests 38 | .wrap(Logger::default()) 39 | // Configure the application routes defined in the routes module 40 | .configure(routes::init_routes) 41 | }) 42 | .bind(server_address)? 43 | .run() 44 | .await 45 | } 46 | -------------------------------------------------------------------------------- /migrations/20230310123456_create_urls/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE urls; 2 | -------------------------------------------------------------------------------- /migrations/20230310123456_create_urls/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE urls ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | original_url TEXT NOT NULL, 4 | short_code TEXT NOT NULL UNIQUE, 5 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/20230310123567_create_usage_logs/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE usage_logs; 2 | -------------------------------------------------------------------------------- /migrations/20230310123567_create_usage_logs/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE usage_logs ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | url_id INTEGER NOT NULL, 4 | accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 5 | FOREIGN KEY (url_id) REFERENCES urls(id) 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/20230310123678_create_redirect_stats/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE redirect_stats; 2 | -------------------------------------------------------------------------------- /migrations/20230310123678_create_redirect_stats/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE redirect_stats ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | url_id INTEGER NOT NULL, 4 | ip_address TEXT, 5 | user_agent TEXT, 6 | accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 7 | FOREIGN KEY (url_id) REFERENCES urls(id) 8 | ); 9 | -------------------------------------------------------------------------------- /migrations/20230310123789_add_expiration_date_to_urls/down.sql: -------------------------------------------------------------------------------- 1 | -- SQLite does not support dropping columns directly. 2 | -- We will need to recreate the table without the column if rolling back. 3 | PRAGMA foreign_keys=off; 4 | 5 | CREATE TABLE urls_temp ( 6 | id INTEGER PRIMARY KEY AUTOINCREMENT, 7 | original_url TEXT NOT NULL, 8 | short_code TEXT NOT NULL UNIQUE, 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | INSERT INTO urls_temp (id, original_url, short_code, created_at) 13 | SELECT id, original_url, short_code, created_at FROM urls; 14 | 15 | DROP TABLE urls; 16 | 17 | ALTER TABLE urls_temp RENAME TO urls; 18 | 19 | PRAGMA foreign_keys=on; 20 | -------------------------------------------------------------------------------- /migrations/20230310123789_add_expiration_date_to_urls/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE urls 2 | ADD COLUMN expiration_date TIMESTAMP; 3 | -------------------------------------------------------------------------------- /models.rs: -------------------------------------------------------------------------------- 1 | use crate::schema::urls; 2 | use chrono::NaiveDateTime; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Queryable, Serialize)] 6 | pub struct Url { 7 | pub id: i32, 8 | pub original_url: String, 9 | pub short_code: String, 10 | pub created_at: NaiveDateTime, 11 | } 12 | 13 | #[derive(Insertable, Deserialize)] 14 | #[diesel(table_name = urls)] 15 | pub struct NewUrl { 16 | pub original_url: String, 17 | pub short_code: String, 18 | } 19 | -------------------------------------------------------------------------------- /routes.rs: -------------------------------------------------------------------------------- 1 | // Import necessary Actix-web items for routing, handling HTTP responses, and asynchronous functionality. 2 | use actix_web::{get, post, web, HttpResponse, Responder}; 3 | // Import Diesel prelude for database interaction. 4 | use diesel::prelude::*; 5 | // Import rand for generating random alphanumeric characters. 6 | use rand::{distributions::Alphanumeric, Rng}; 7 | // Import std::env to access environment variables. 8 | use std::env; 9 | 10 | // Import the database connection pool type defined in our project. 11 | use crate::db::DbPool; 12 | // Import Diesel models for URL data and the corresponding insertable struct. 13 | use crate::models::{NewUrl, Url}; 14 | // Import Diesel schema DSL for the "urls" table. 15 | use crate::schema::urls::dsl::*; 16 | 17 | /// Handler for creating a new shortened URL. 18 | /// 19 | /// This function expects a JSON payload containing the `original_url` field. 20 | /// It validates the input, generates a random 7-character short code, and then attempts 21 | /// to insert the new URL into the database using Diesel. 22 | /// 23 | /// On success, it returns a 201 Created response with a JSON payload containing the original URL, 24 | /// generated short code, full short URL, and creation timestamp. 25 | /// On failure, it returns an appropriate error response. 26 | #[post("/")] 27 | async fn create_url( 28 | // Web::Data provides shared state (database pool) to the handler. 29 | pool: web::Data, 30 | // Deserialize the incoming JSON into a NewUrlRequest struct. 31 | item: web::Json, 32 | ) -> impl Responder { 33 | // Retrieve a database connection from the pool. 34 | let conn = pool.get().expect("Couldn't get db connection from pool"); 35 | 36 | // Validate the provided URL. Here we simply check if it's not empty. 37 | if item.original_url.trim().is_empty() { 38 | return HttpResponse::BadRequest().body("Original URL is required"); 39 | } 40 | 41 | // Generate a random alphanumeric short code of 7 characters. 42 | let generated_code: String = rand::thread_rng() 43 | .sample_iter(&Alphanumeric) 44 | .take(7) 45 | .map(char::from) 46 | .collect(); 47 | 48 | // Create a new URL entry using the provided original URL and generated short code. 49 | let new_url = NewUrl { 50 | original_url: item.original_url.clone(), 51 | short_code: generated_code.clone(), 52 | }; 53 | 54 | // Insert the new URL into the database on a separate thread using web::block. 55 | let inserted_url = web::block(move || { 56 | // Use the Diesel insert_into function to add the new record into the urls table. 57 | use crate::schema::urls; 58 | diesel::insert_into(urls::table) 59 | .values(&new_url) 60 | // Execute the insertion and then, on success, query back the inserted record. 61 | .execute(&conn) 62 | .and_then(|_| { 63 | // Filter by the generated short code to fetch the new record. 64 | urls.filter(short_code.eq(&generated_code)) 65 | .first::(&conn) 66 | }) 67 | }) 68 | .await; 69 | 70 | // Match on the result of the insertion. 71 | match inserted_url { 72 | // If successful, build the full short URL using the BASE_URL environment variable. 73 | Ok(url_entry) => { 74 | let base = env::var("BASE_URL") 75 | .unwrap_or_else(|_| "http://localhost:8080".to_string()); 76 | let short_url = format!("{}/{}", base, url_entry.short_code); 77 | // Return a 201 Created response with the URL details in JSON format. 78 | HttpResponse::Created().json(serde_json::json!({ 79 | "original_url": url_entry.original_url, 80 | "short_url": short_url, 81 | "short_code": url_entry.short_code, 82 | "created_at": url_entry.created_at 83 | })) 84 | } 85 | // If an error occurs, log the error and return a 500 Internal Server Error. 86 | Err(err) => { 87 | eprintln!("Error inserting URL: {:?}", err); 88 | HttpResponse::InternalServerError().body("Error creating short URL") 89 | } 90 | } 91 | } 92 | 93 | /// Handler for listing all shortened URLs. 94 | /// 95 | /// This function fetches all URL entries from the database, ordered by creation date in descending order. 96 | /// It then returns the data as JSON in a 200 OK response. 97 | /// On failure, it logs the error and returns a 500 Internal Server Error. 98 | #[get("/")] 99 | async fn list_urls(pool: web::Data) -> impl Responder { 100 | // Retrieve a database connection from the pool. 101 | let conn = pool.get().expect("Couldn't get db connection from pool"); 102 | 103 | // Execute a query on a separate thread to fetch URL records. 104 | let urls_data = web::block(move || urls.order(created_at.desc()).load::(&conn)).await; 105 | match urls_data { 106 | Ok(data) => HttpResponse::Ok().json(data), 107 | Err(err) => { 108 | eprintln!("Error loading URLs: {:?}", err); 109 | HttpResponse::InternalServerError().body("Error loading URLs") 110 | } 111 | } 112 | } 113 | 114 | /// Handler for redirecting a short URL to its corresponding original URL. 115 | /// 116 | /// This function extracts the `code` from the URL path, queries the database for a matching record, 117 | /// and if found, returns a 302 Found response with the Location header set to the original URL. 118 | /// If no matching record is found, it returns a 404 Not Found response. 119 | #[get("/{code}")] 120 | async fn redirect_url( 121 | pool: web::Data, 122 | // Extract the path parameter "code" as a String. 123 | web::Path(code): web::Path, 124 | ) -> impl Responder { 125 | // Retrieve a database connection from the pool. 126 | let conn = pool.get().expect("Couldn't get db connection from pool"); 127 | 128 | // Execute a query to find the URL with the matching short code. 129 | let result = web::block(move || { 130 | urls.filter(short_code.eq(code)) 131 | .first::(&conn) 132 | }) 133 | .await; 134 | 135 | // If the URL is found, return a 302 Found response with the Location header set. 136 | match result { 137 | Ok(url_entry) => HttpResponse::Found() 138 | .append_header(("Location", url_entry.original_url)) 139 | .finish(), 140 | // Otherwise, return a 404 Not Found response. 141 | Err(_) => HttpResponse::NotFound().body("URL not found"), 142 | } 143 | } 144 | 145 | /// Structure for deserializing a new URL creation request. 146 | /// 147 | /// This struct expects a JSON object with an "original_url" field. 148 | #[derive(serde::Deserialize)] 149 | pub struct NewUrlRequest { 150 | pub original_url: String, 151 | } 152 | 153 | /// Initializes the application's routes. 154 | /// 155 | /// This function registers the three handlers (create, list, and redirect) with the Actix-web service configuration. 156 | pub fn init_routes(cfg: &mut web::ServiceConfig) { 157 | cfg.service(create_url); 158 | cfg.service(list_urls); 159 | cfg.service(redirect_url); 160 | } 161 | -------------------------------------------------------------------------------- /schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | urls (id) { 5 | id -> Integer, 6 | original_url -> Text, 7 | short_code -> Text, 8 | created_at -> Timestamp, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/integrationTests.rs: -------------------------------------------------------------------------------- 1 | 2 | use reqwest; 3 | use serde_json::json; 4 | use std::{thread, time}; 5 | 6 | /// This test sends a POST request to create a shortened URL and verifies 7 | /// that the response contains the expected fields. 8 | #[test] 9 | fn test_create_url() { 10 | // Wait briefly to ensure the server is up (adjust if needed) 11 | thread::sleep(time::Duration::from_secs(2)); 12 | 13 | let client = reqwest::blocking::Client::new(); 14 | 15 | // Send a POST request to create a shortened URL 16 | let response = client 17 | .post("http://localhost:8080/") 18 | .json(&json!({ "original_url": "https://example.com" })) 19 | .send() 20 | .expect("Failed to send POST request"); 21 | 22 | // Ensure we receive a "Created" (201) status 23 | assert_eq!(response.status(), 201, "Expected status 201 Created"); 24 | 25 | // Parse the JSON response and check for the presence of required fields 26 | let body: serde_json::Value = response.json().expect("Failed to parse JSON response"); 27 | assert!( 28 | body.get("short_code").is_some(), 29 | "Response JSON should contain 'short_code'" 30 | ); 31 | assert!( 32 | body.get("short_url").is_some(), 33 | "Response JSON should contain 'short_url'" 34 | ); 35 | println!("Created URL response: {}", body); 36 | } 37 | 38 | /// This test sends a GET request to list all shortened URLs and verifies 39 | /// that the response is a JSON array. 40 | #[test] 41 | fn test_list_urls() { 42 | // Wait briefly to ensure the server is up (adjust if needed) 43 | thread::sleep(time::Duration::from_secs(2)); 44 | 45 | let client = reqwest::blocking::Client::new(); 46 | 47 | // Send a GET request to fetch all shortened URLs 48 | let response = client 49 | .get("http://localhost:8080/") 50 | .send() 51 | .expect("Failed to send GET request"); 52 | 53 | // Ensure we receive a successful (200 OK) status 54 | assert_eq!(response.status(), 200, "Expected status 200 OK"); 55 | 56 | // Parse the JSON response and verify that it is an array 57 | let urls: serde_json::Value = response.json().expect("Failed to parse JSON response"); 58 | assert!(urls.is_array(), "Response should be a JSON array"); 59 | println!("List of URLs: {}", urls); 60 | } 61 | -------------------------------------------------------------------------------- /utils.rs: -------------------------------------------------------------------------------- 1 | use rand::{distributions::Alphanumeric, Rng}; 2 | 3 | /// Generates a random alphanumeric short code with the specified length. 4 | pub fn generate_short_code(len: usize) -> String { 5 | rand::thread_rng() 6 | .sample_iter(&Alphanumeric) 7 | .take(len) 8 | .map(char::from) 9 | .collect() 10 | } 11 | --------------------------------------------------------------------------------