A fast, lightweight URL shortener built with Rust, Actix-web, and SQLite.
- ✨ Create short URLs from long URLs
- 🎯 Custom short codes - use your own memorable codes
- ✏️ Update destination - change a URL's target without losing the short code or click history
- ⏰ Expiring URLs - set expiration time in hours
- 📊 Click tracking - track clicks with parsed browser, OS, device type, and referer domain
- 📈 Analytics - view click logs, timeline charts, referrer/browser/device breakdowns
- 🔧 Configurable click logging - enable/disable logging, set retention period
- 🔐 Per-user authentication - email registration with API key management
- 🔑 Multiple API keys - create, list, and revoke API keys per user
- 👤 URL ownership - users can only access their own URLs
- 📦 Bulk operations - create or delete up to 100 URLs in a single request
- 🏷️ Tags/Categories - organize URLs with user-defined tags
- 🔍 Search - find URLs by original URL or short code
- 🛡️ Rate limiting - 60 requests/minute per IP to prevent abuse
- ⚡ In-memory caching - moka-based caching for URL redirects and API key validation
- 📊 Prometheus metrics - monitor performance, cache efficiency, and business metrics
- 📱 QR codes - generate QR codes for short URLs in PNG or SVG format
- 🐳 Docker support - multi-stage Dockerfile and docker-compose for easy deployment
- 🚀 Blazing fast - built with Rust and Actix-web
- 💾 SQLite storage - no database server required
- ⚡ WAL mode - SQLite Write-Ahead Logging for better concurrency
- 🔒 Atomic operations - transaction-based click recording for data consistency
This project demonstrates:
- Routing - RESTful API design with Actix-web
- IDs & Short Codes - Generating unique, URL-safe identifiers with nanoid
- Persistence - SQLite database with connection pooling (r2d2)
- Error Handling - Custom error types with proper HTTP responses
- Validation - Input validation with the validator crate
- Middleware - Logging and rate limiting middleware
- Rate Limiting - Request throttling with actix-governor
- Database Optimization - WAL mode for concurrent read/write performance
- Transactions - Atomic operations for data consistency
- Authentication - Per-user API key authentication with SHA-256 hashing
- Authorization - Resource ownership and access control
- Caching - In-memory caching with TTL and automatic invalidation
- Metrics - Prometheus metrics for observability and monitoring
- QR Code Generation - Creating QR codes with the qrcode crate
- Query Optimization - Avoiding N+1 queries with batch loading patterns
- Code Organization - Row mapping helpers and reusable ownership checks
- Containerization - Multi-stage Docker builds and docker-compose
- Testing - Unit and integration tests (192 tests) with shared test utilities
- Constants Management - Centralized magic numbers and configuration defaults
- Error Constructors - Domain-specific error factory methods for cleaner code
- User-Agent Parsing - Extracting browser, OS, and device type from UA strings with woothee
- Aggregated Analytics - SQL GROUP BY queries for timeline and breakdown reports
url-shortener/
├── Cargo.toml # Dependencies and project metadata
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose configuration
├── .dockerignore # Docker build exclusions
├── .env # Environment configuration
├── .env.example # Example configuration
├── .gitignore # Git ignore rules
├── README.md # This file
└── src/
├── main.rs # Application entry point and server setup
├── auth.rs # API key authentication extractor
├── test_utils.rs # Shared test infrastructure (test-only)
├── infra/ # Cross-cutting infrastructure
│ ├── cache.rs # In-memory caching for URLs and API keys
│ ├── config.rs # Configuration management
│ ├── constants.rs # Centralized application constants
│ ├── db.rs # Database pool, WAL configuration, and migrations
│ ├── errors.rs # Custom error types and HTTP response mapping
│ ├── metrics.rs # Prometheus metrics for monitoring
│ └── qr.rs # QR code generation
├── models/ # DTOs and DB entities, split by domain
│ ├── db.rs # Database entity structs
│ ├── url.rs # URL request/response DTOs
│ ├── auth.rs # Auth request/response DTOs
│ ├── tag.rs # Tag request/response DTOs
│ ├── analytics.rs # Analytics query params and responses
│ ├── bulk.rs # Bulk operation DTOs
│ ├── qr.rs # QR code query params
│ ├── common.rs # Shared response wrappers
│ └── validators.rs # Custom validators
├── queries/ # SQL query constants, split by domain
│ ├── schema.rs # Schema DDL and migrations
│ ├── users.rs # User queries
│ ├── api_keys.rs # API key queries
│ ├── urls.rs # URL queries
│ ├── click_logs.rs # Click log queries
│ └── tags.rs # Tag and url_tags queries
├── services/ # Business logic, split by domain
│ ├── helpers.rs # Cross-cutting helpers (ownership, key gen)
│ ├── auth.rs # User registration and API key management
│ ├── urls.rs # URL CRUD, search, caching
│ ├── tags.rs # Tag CRUD and URL-tag associations
│ ├── analytics.rs # Click tracking and aggregation
│ └── bulk.rs # Bulk create/delete
└── handlers/ # HTTP route handlers, split by domain
├── auth.rs # Auth endpoints
├── urls.rs # URL CRUD endpoints
├── tags.rs # Tag endpoints
├── analytics.rs # Analytics endpoints
├── bulk.rs # Bulk operation endpoints
├── redirect.rs # Public redirect handler
└── health.rs # Health check
- Rust (1.70 or later)
- JetBrains RustRover or CLion with Rust plugin
Open the project folder in JetBrains RustRover.
cargo buildcargo runThe server will start at http://localhost:8080
cargo testAll /api/* endpoints (except /api/auth/register) require authentication via API key.
Provide the API key using one of these headers:
X-API-Key: usk_your_key_hereAuthorization: Bearer usk_your_key_here
POST /api/auth/register
Content-Type: application/json
{
"email": "user@example.com"
}Response (201 Created):
{
"user_id": 1,
"email": "user@example.com",
"api_key": "usk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
}
⚠️ Save your API key! It is only shown once at registration.
POST /api/auth/keys
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"name": "CI/CD key"
}Response (201 Created):
{
"id": 2,
"name": "CI/CD key",
"api_key": "usk_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4",
"created_at": "2024-01-01 12:00:00"
}GET /api/auth/keys
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"keys": [
{
"id": 1,
"name": "Default key",
"created_at": "2024-01-01 12:00:00",
"last_used_at": "2024-01-15 08:30:00",
"is_active": true
}
]
}DELETE /api/auth/keys/{id}
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"message": "API key revoked successfully"
}POST /api/shorten
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"url": "https://example.com/very/long/url/that/needs/shortening",
"custom_code": "mylink", # optional - custom short code
"expires_in_hours": 24 # optional - URL expiration
}Response (201 Created):
{
"short_code": "mylink",
"short_url": "http://localhost:8080/mylink",
"original_url": "https://example.com/very/long/url/that/needs/shortening",
"created_at": "2024-01-01 12:00:00",
"expires_at": "2024-01-02 12:00:00"
}GET /{short_code}Response: 301 Permanent Redirect to the original URL
GET /api/urls?page=1&limit=20&sort=desc
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"total": 42,
"urls": [
{
"id": 1,
"short_code": "abc123",
"short_url": "http://localhost:8080/abc123",
"original_url": "https://example.com",
"clicks": 150,
"created_at": "2024-01-01 12:00:00",
"updated_at": "2024-01-15 08:30:00",
"expires_at": null
}
]
}Note: Only returns URLs owned by the authenticated user.
Search your URLs by original URL and/or short code (case-insensitive partial match).
# Search by original URL
GET /api/urls/search?q=github
X-API-Key: usk_your_key_here
# Search by short code
GET /api/urls/search?code=proj
X-API-Key: usk_your_key_here
# Search by both (must match both criteria)
GET /api/urls/search?q=github&code=proj
X-API-Key: usk_your_key_here
# With custom limit
GET /api/urls/search?q=example&limit=50
X-API-Key: usk_your_key_hereQuery Parameters:
q: Search term for original URL (partial match, case-insensitive)code: Search term for short code (partial match, case-insensitive)limit: Maximum results (default: 20, max: 100)
At least one of
qorcodeis required.
Response (200 OK):
{
"total": 2,
"urls": [
{
"id": 1,
"short_code": "gh-proj",
"short_url": "http://localhost:8080/gh-proj",
"original_url": "https://github.com/myproject",
"clicks": 42,
"created_at": "2024-01-01 12:00:00",
"updated_at": "2024-01-15 08:30:00",
"expires_at": null
}
]
}GET /api/urls/{id}
X-API-Key: usk_your_key_hereChange the destination URL while keeping the same short code, click history, and creation timestamp.
PUT /api/urls/{id}
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"url": "https://new-destination.com"
}Response (200 OK):
{
"id": 1,
"short_code": "abc123",
"short_url": "http://localhost:8080/abc123",
"original_url": "https://new-destination.com",
"clicks": 42,
"created_at": "2024-01-01 12:00:00",
"updated_at": "2024-01-15 09:00:00",
"expires_at": null
}Only the destination is mutable. The short code, click count, click history, and creation timestamp are preserved. The redirect cache is invalidated so the next hit serves the new destination immediately.
GET /api/urls/{id}/stats
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"url": { ... },
"recent_clicks": [
{
"id": 1,
"url_id": 1,
"clicked_at": "2024-01-15 08:30:00",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"referer": "https://google.com",
"browser": "Chrome",
"browser_version": "120.0.0.0",
"os": "Windows 10",
"device_type": "desktop",
"referer_domain": "google.com"
}
]
}Get aggregated click counts grouped by time period.
GET /api/urls/{id}/analytics/timeline?period=daily&limit=7
X-API-Key: usk_your_key_hereQuery Parameters:
period:hourly,daily(default), orweeklylimit: Maximum buckets to return (default: 30, max: 100)
Response (200 OK):
{
"period": "daily",
"data": [
{ "bucket": "2024-01-15", "count": 42 },
{ "bucket": "2024-01-14", "count": 38 },
{ "bucket": "2024-01-13", "count": 25 }
]
}Get top referring domains for a URL.
GET /api/urls/{id}/analytics/referrers?limit=10
X-API-Key: usk_your_key_hereQuery Parameters:
limit: Maximum entries (default: 20, max: 100)
Response (200 OK):
{
"data": [
{ "name": "google.com", "count": 85 },
{ "name": "twitter.com", "count": 32 },
{ "name": "direct", "count": 18 }
]
}Get browser usage breakdown for a URL.
GET /api/urls/{id}/analytics/browsers?limit=10
X-API-Key: usk_your_key_hereQuery Parameters:
limit: Maximum entries (default: 20, max: 100)
Response (200 OK):
{
"data": [
{ "name": "Chrome", "count": 120 },
{ "name": "Firefox", "count": 45 },
{ "name": "Safari", "count": 30 }
]
}Get device type breakdown for a URL.
GET /api/urls/{id}/analytics/devices?limit=10
X-API-Key: usk_your_key_hereQuery Parameters:
limit: Maximum entries (default: 20, max: 100)
Response (200 OK):
{
"data": [
{ "name": "desktop", "count": 150 },
{ "name": "mobile", "count": 80 },
{ "name": "bot", "count": 12 },
{ "name": "other", "count": 3 }
]
}Generate a QR code image for a short URL.
GET /api/urls/{id}/qr
GET /api/urls/{id}/qr?format=svg
GET /api/urls/{id}/qr?format=png&size=512
X-API-Key: usk_your_key_hereQuery Parameters:
format: Output format -png(default) orsvgsize: Size in pixels (default: 256, min: 64, max: 1024)
Response:
image/png- PNG image of the QR code (default)image/svg+xml- SVG image of the QR code
The QR code encodes the full short URL (e.g., http://localhost:8080/abc123).
DELETE /api/urls/{id}
X-API-Key: usk_your_key_hereCreate multiple URLs in a single request (max 100).
POST /api/urls/bulk
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"urls": [
{ "url": "https://example1.com", "custom_code": "ex1" },
{ "url": "https://example2.com", "expires_in_hours": 24 },
{ "url": "https://example3.com" }
]
}Response (201 Created - all succeeded):
{
"status": "success",
"total": 3,
"succeeded": 3,
"failed": 0,
"results": [
{ "index": 0, "success": true, "data": { "short_code": "ex1", ... } },
{ "index": 1, "success": true, "data": { "short_code": "a1b2c3d", ... } },
{ "index": 2, "success": true, "data": { "short_code": "x9y8z7w", ... } }
]
}Response (207 Multi-Status - partial success):
{
"status": "partial_success",
"total": 2,
"succeeded": 1,
"failed": 1,
"results": [
{ "index": 0, "success": true, "data": { ... } },
{ "index": 1, "success": false, "error": { "code": "DUPLICATE_CODE", "message": "..." } }
]
}Delete multiple URLs by ID in a single request (max 100).
DELETE /api/urls/bulk
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"ids": [1, 2, 3]
}Response (200 OK - all succeeded):
{
"status": "success",
"total": 3,
"succeeded": 3,
"failed": 0,
"results": [
{ "id": 1, "success": true },
{ "id": 2, "success": true },
{ "id": 3, "success": true }
]
}Response (207 Multi-Status - partial success):
{
"status": "partial_success",
"total": 3,
"succeeded": 2,
"failed": 1,
"results": [
{ "id": 1, "success": true },
{ "id": 2, "success": true },
{ "id": 999, "success": false, "error": { "code": "NOT_FOUND", "message": "..." } }
]
}POST /api/tags
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"name": "Important"
}Response (201 Created):
{
"id": 1,
"name": "Important",
"created_at": "2024-01-01 12:00:00"
}GET /api/tags
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"tags": [
{ "id": 1, "name": "Important", "created_at": "2024-01-01 12:00:00" },
{ "id": 2, "name": "Work", "created_at": "2024-01-01 12:00:00" }
]
}DELETE /api/tags/{id}
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"message": "Tag deleted successfully"
}Note: Deleting a tag removes it from all associated URLs.
POST /api/urls/{id}/tags
X-API-Key: usk_your_key_here
Content-Type: application/json
{
"tag_id": 1
}Response (201 Created):
{
"message": "Tag added to URL successfully"
}DELETE /api/urls/{id}/tags/{tag_id}
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"message": "Tag removed from URL successfully"
}GET /api/tags/{id}/urls
X-API-Key: usk_your_key_hereResponse (200 OK):
{
"urls": [
{
"id": 1,
"short_code": "abc123",
"short_url": "http://localhost:8080/abc123",
"original_url": "https://example.com",
"clicks": 42,
"created_at": "2024-01-01 12:00:00",
"updated_at": "2024-01-15 08:30:00",
"expires_at": null,
"tags": [
{ "id": 1, "name": "Important", "created_at": "2024-01-01 12:00:00" }
]
}
]
}GET /healthResponse (200 OK):
{
"status": "healthy",
"version": "0.1.0"
}The API returns consistent error responses with appropriate HTTP status codes:
| Status Code | Error Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR |
Invalid input (bad URL format, invalid custom code) |
| 401 | UNAUTHORIZED |
Missing or invalid API key |
| 403 | FORBIDDEN |
Not allowed to access this resource |
| 404 | NOT_FOUND |
URL or resource not found |
| 409 | DUPLICATE_CODE |
Custom short code already exists |
| 409 | EMAIL_ALREADY_EXISTS |
Email is already registered |
| 410 | EXPIRED_URL |
URL has expired |
| 429 | RATE_LIMIT_EXCEEDED |
Too many requests |
| 500 | INTERNAL_ERROR |
Server error |
Example error response:
{
"error": "Missing API key. Provide via 'Authorization: Bearer <key>' or 'X-API-Key: <key>' header",
"code": "UNAUTHORIZED"
}The API is protected by rate limiting to prevent abuse:
- Limit: 60 requests per minute per IP address
- Burst: Up to 60 requests allowed in a burst
- Response: Returns
429 Too Many Requestswhen limit is exceeded
The application uses in-memory caching (via moka) to optimize the two most frequently accessed operations:
| Operation | Cache Key | TTL | Max Capacity |
|---|---|---|---|
URL redirects (GET /{short_code}) |
short_code |
5 min | 10,000 |
| API key validation | key_hash |
10 min | 1,000 |
- Cache Miss: On first access, data is fetched from the database and stored in cache
- Cache Hit: Subsequent requests are served from cache without database queries
- Automatic Expiration: Entries expire after their TTL (time-to-live)
- Memory Bounded: Cache evicts oldest entries when max capacity is reached
The cache is automatically invalidated when data changes:
| Operation | Invalidation |
|---|---|
| URL deleted | Cache entry for that short_code removed |
| Bulk URL delete | All affected short_code entries removed |
| API key revoked | Cache entry for that key_hash removed |
| URL expired | Detected on cache hit, entry removed |
- Reduced database load: Hot URLs and frequently-used API keys are served from memory
- Lower latency: Cache hits avoid database round-trips
- Lock-free concurrency:
mokaprovides thread-safe access without locks
The application exposes Prometheus metrics at /metrics for monitoring and observability.
# Get all metrics
curl http://localhost:8080/metrics| Metric | Type | Labels | Description |
|---|---|---|---|
url_shortener_http_requests_total |
Counter | endpoint, method, status | Total HTTP requests |
url_shortener_http_request_duration_seconds |
Histogram | endpoint, method | Request latency distribution |
| Metric | Type | Labels | Description |
|---|---|---|---|
url_shortener_cache_hits_total |
Counter | cache_type | Cache hits (url, api_key) |
url_shortener_cache_misses_total |
Counter | cache_type | Cache misses (url, api_key) |
url_shortener_redirects_total |
Counter | - | Total URL redirects performed |
url_shortener_urls_created_total |
Counter | - | Total URLs created |
url_shortener_api_key_validations_total |
Counter | result | API key validations (success, invalid) |
# Requests per second
rate(url_shortener_http_requests_total[5m])
# Redirects per second
rate(url_shortener_redirects_total[5m])
# Cache hit ratio for URL lookups
url_shortener_cache_hits_total{cache_type="url"} /
(url_shortener_cache_hits_total{cache_type="url"} + url_shortener_cache_misses_total{cache_type="url"})
# Error rate (5xx responses)
rate(url_shortener_http_requests_total{status=~"5.."}[5m])
# API key validation failure rate
rate(url_shortener_api_key_validations_total{result="invalid"}[5m])
Set the environment variable to disable the metrics endpoint:
METRICS_ENABLED=falseThe application uses SQLite with six tables:
users - Stores registered users
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
email |
TEXT | Unique email address |
created_at |
TEXT | Registration timestamp |
api_keys - Stores API keys for authentication
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
user_id |
INTEGER | Foreign key to users |
key_hash |
TEXT | SHA-256 hash of API key |
name |
TEXT | Human-readable key name |
created_at |
TEXT | Creation timestamp |
last_used_at |
TEXT | Last usage timestamp |
is_active |
INTEGER | Whether key is active (1) or revoked (0) |
urls - Stores shortened URLs
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
short_code |
TEXT | Unique short code (indexed) |
original_url |
TEXT | Original URL |
clicks |
INTEGER | Click counter |
created_at |
TEXT | Creation timestamp |
updated_at |
TEXT | Last update timestamp |
expires_at |
TEXT | Optional expiration timestamp |
user_id |
INTEGER | Foreign key to users (owner) |
click_logs - Stores click analytics (indexed on url_id and url_id, clicked_at)
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
url_id |
INTEGER | Foreign key to urls |
clicked_at |
TEXT | Click timestamp |
ip_address |
TEXT | Visitor IP address |
user_agent |
TEXT | Raw browser user agent |
referer |
TEXT | Raw referring URL |
browser |
TEXT | Parsed browser name (e.g., Chrome, Firefox) |
browser_version |
TEXT | Parsed browser version |
os |
TEXT | Parsed operating system (e.g., Windows 10) |
device_type |
TEXT | Device category: desktop, mobile, bot, other |
referer_domain |
TEXT | Extracted domain from referer URL |
tags - Stores user-defined tags
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Primary key |
name |
TEXT | Tag name (unique per user) |
user_id |
INTEGER | Foreign key to users |
created_at |
TEXT | Creation timestamp |
url_tags - Junction table for URL-tag associations
| Column | Type | Description |
|---|---|---|
url_id |
INTEGER | Foreign key to urls |
tag_id |
INTEGER | Foreign key to tags |
| PRIMARY KEY | (url_id, tag_id) | Composite key |
Environment variables (set in .env file):
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
urls.db |
SQLite database file path |
HOST |
127.0.0.1 |
Server host address |
PORT |
8080 |
Server port |
BASE_URL |
http://localhost:8080 |
Base URL for generated short links |
SHORT_CODE_LENGTH |
7 |
Length of auto-generated codes |
RUST_LOG |
info |
Logging level (debug, info, warn, error) |
URL_CACHE_TTL_SECS |
300 |
URL cache time-to-live in seconds (5 min) |
URL_CACHE_MAX_CAPACITY |
10000 |
Maximum number of URLs to cache |
API_KEY_CACHE_TTL_SECS |
600 |
API key cache time-to-live in seconds (10 min) |
API_KEY_CACHE_MAX_CAPACITY |
1000 |
Maximum number of API keys to cache |
METRICS_ENABLED |
true |
Enable Prometheus metrics endpoint at /metrics |
CLICK_LOGGING_ENABLED |
true |
Enable/disable click logging on redirects |
CLICK_RETENTION_DAYS |
(none) | Auto-delete click logs older than N days at startup |
SHUTDOWN_TIMEOUT_SECS |
30 |
Drain timeout for in-flight requests on SIGTERM/SIGINT |
The easiest way to run the application:
# Build and start the container
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the container
docker-compose down
# Stop and remove data volume
docker-compose down -vThe application will be available at http://localhost:8080.
# Build the image
docker build -t url-shortener .
# Run the container
docker run -d \
--name url-shortener \
-p 8080:8080 \
-v url-shortener-data:/app/data \
-e BASE_URL=http://localhost:8080 \
url-shortenerThe Docker image uses these default environment variables:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
/app/data/urls.db |
SQLite database path (inside container) |
HOST |
0.0.0.0 |
Listen on all interfaces |
PORT |
8080 |
Server port |
BASE_URL |
http://localhost:8080 |
Base URL for short links |
RUST_LOG |
info |
Logging level |
METRICS_ENABLED |
true |
Enable Prometheus metrics |
Override any variable using -e flag or in docker-compose.yml.
The server listens for SIGTERM and SIGINT (Ctrl+C). On signal it stops
accepting new connections and waits up to SHUTDOWN_TIMEOUT_SECS (default
30 s) for in-flight requests to finish before exiting.
When running under Docker, set the orchestrator's grace period to match.
Docker's default stop_grace_period is 10 s, which would cut requests
short — bump it in docker-compose.yml:
services:
url-shortener:
stop_grace_period: 35s # SHUTDOWN_TIMEOUT_SECS + a small bufferFor production, update BASE_URL to your domain:
docker run -d \
--name url-shortener \
-p 8080:8080 \
-v url-shortener-data:/app/data \
-e BASE_URL=https://your-domain.com \
-e RUST_LOG=warn \
url-shortenerOr modify docker-compose.yml:
environment:
- BASE_URL=https://your-domain.com
- RUST_LOG=warnThe container includes a health check that polls /health every 30 seconds:
# Check container health status
docker inspect --format='{{.State.Health.Status}}' url-shortenerSQLite data is stored in a Docker volume (url-shortener-data) mounted at /app/data. To backup:
# Backup database
docker cp url-shortener:/app/data/urls.db ./backup-urls.db
# Restore database
docker cp ./backup-urls.db url-shortener:/app/data/urls.db# Register a new user (save the api_key from response!)
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# Create a short URL (replace YOUR_API_KEY)
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"url": "https://www.rust-lang.org/learn"}'
# Create with custom code
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"url": "https://docs.rs", "custom_code": "docs"}'
# Create with expiration
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"url": "https://temp.example.com", "expires_in_hours": 1}'
# List your URLs
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls
# Search URLs by original URL
curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:8080/api/urls/search?q=github"
# Search URLs by short code
curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:8080/api/urls/search?code=docs"
# Search with both filters
curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:8080/api/urls/search?q=example&code=proj"
# Get URL details
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls/1
# Update a URL's destination (preserves short_code and click history)
curl -X PUT http://localhost:8080/api/urls/1 \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"url": "https://new-destination.com"}'
# Get URL statistics
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls/1/stats
# Get QR code as PNG (default)
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls/1/qr -o qrcode.png
# Get QR code as SVG
curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:8080/api/urls/1/qr?format=svg" -o qrcode.svg
# Get QR code with custom size
curl -H "X-API-Key: YOUR_API_KEY" \
"http://localhost:8080/api/urls/1/qr?size=512" -o qrcode_large.png
# Delete a URL
curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls/1
# Bulk create URLs
curl -X POST http://localhost:8080/api/urls/bulk \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"urls": [{"url": "https://example1.com"}, {"url": "https://example2.com", "custom_code": "ex2"}]}'
# Bulk delete URLs
curl -X DELETE http://localhost:8080/api/urls/bulk \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"ids": [1, 2, 3]}'
# Test redirect (follow redirects) - no auth needed
curl -L http://localhost:8080/docs
# Create a tag
curl -X POST http://localhost:8080/api/tags \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"name": "Important"}'
# List tags
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/tags
# Add tag to URL
curl -X POST http://localhost:8080/api/urls/1/tags \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"tag_id": 1}'
# Get URLs by tag
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/tags/1/urls
# Remove tag from URL
curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/urls/1/tags/1
# Delete tag
curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/tags/1
# Create another API key
curl -X POST http://localhost:8080/api/auth/keys \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{"name": "Backup key"}'
# List your API keys
curl -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/auth/keys
# Revoke an API key
curl -X DELETE -H "X-API-Key: YOUR_API_KEY" \
http://localhost:8080/api/auth/keys/2Here are some ideas for extending this project:
Authentication - Add API keys or JWT authentication✅ Done!QR Codes - Generate QR codes for short URLs✅ Done!- Custom Domains - Support multiple base URLs
Bulk Operations - Create/delete multiple URLs at once✅ Done!Search - Search URLs by original URL or code✅ Done!Tags/Categories - Organize URLs with tags✅ Done!- Web UI - Add a frontend with HTML templates or SPA
Caching - Add Redis or in-memory caching for hot URLs✅ Done!Metrics - Add Prometheus metrics for monitoring✅ Done!Docker Support - Add Dockerfile and docker-compose for deployment✅ Done!
| Crate | Purpose |
|---|---|
actix-web |
Web framework |
actix-governor |
Rate limiting middleware |
rusqlite |
SQLite database |
r2d2 |
Connection pooling |
serde |
Serialization |
serde_json |
JSON support |
nanoid |
Short code generation |
chrono |
Date/time handling |
validator |
Input validation |
thiserror |
Error handling |
env_logger |
Logging |
url |
URL parsing and validation |
regex |
Regular expressions |
lazy_static |
Lazy static initialization |
sha2 |
SHA-256 hashing for API keys |
rand |
Random generation for API keys |
moka |
In-memory caching with TTL support |
actix-web-prom |
Prometheus metrics middleware |
prometheus |
Prometheus metrics library |
qrcode |
QR code generation |
image |
Image encoding (PNG) for QR codes |
woothee |
User-agent parsing (browser, OS, device type) |
MIT License - feel free to use this project for learning and building!