Backend Development with FastAPI
Back to Build Real APIs

Build 2 — URL Shortener Service

Build a URL shortener with custom slugs, click tracking, expiry, and Redis rate limiting.

113 minutesProject submission
🎯 Free Guest Mode: You are learning for free. Sign in to save your completion progress and quiz answers.

Assignment Instructions

The brief

Build a URL shortener. POST /shorten returns a short code; GET /:code redirects to the long URL. Click tracking; rate limiting; custom slugs.

Step 1: Setup

Similar to Build 1; add Redis for rate limiting:

uv add fastapi 'uvicorn[standard]' sqlalchemy redis pydantic-settings

Step 2: Data model

from datetime import datetime
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

class URL(Base):
    __tablename__ = "urls"
    short_code: Mapped[str] = mapped_column(String(20), primary_key=True)
    long_url: Mapped[str] = mapped_column(String(2048))
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
    click_count: Mapped[int] = mapped_column(default=0)
    creator_id: Mapped[int | None] = mapped_column(nullable=True)
    expires_at: Mapped[datetime | None] = mapped_column(nullable=True)

Step 3: Short-code generation

import secrets
import string

def generate_short_code(length: int = 7) -> str:
    chars = string.ascii_letters + string.digits
    return "".join(secrets.choice(chars) for _ in range(length))

Each code: 62^7 ≈ 3.5T possibilities. Collision rate is low for moderate volumes; check before insert just in case.

Step 4: Endpoints

from pydantic import BaseModel, HttpUrl, Field
from fastapi.responses import RedirectResponse
from sqlalchemy import select

class ShortenRequest(BaseModel):
    url: HttpUrl
    custom_slug: str | None = Field(default=None, min_length=3, max_length=20)
    expires_in_days: int | None = None

class ShortenResponse(BaseModel):
    short_code: str
    short_url: str
    long_url: str

@app.post("/shorten", response_model=ShortenResponse)
def shorten(payload: ShortenRequest, db: Session = Depends(get_db)):
    if payload.custom_slug:
        existing = db.get(URL, payload.custom_slug)
        if existing:
            raise HTTPException(409, "slug taken")
        code = payload.custom_slug
    else:
        for _ in range(5):
            code = generate_short_code()
            if not db.get(URL, code):
                break
        else:
            raise HTTPException(500, "could not generate unique code")

    expires_at = None
    if payload.expires_in_days:
        expires_at = datetime.utcnow() + timedelta(days=payload.expires_in_days)

    url = URL(short_code=code, long_url=str(payload.url), expires_at=expires_at)
    db.add(url); db.commit()
    return ShortenResponse(short_code=code, short_url=f"{settings.base_url}/{code}", long_url=str(payload.url))

@app.get("/{short_code}")
def redirect(short_code: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
    url = db.get(URL, short_code)
    if not url:
        raise HTTPException(404)
    if url.expires_at and url.expires_at < datetime.utcnow():
        raise HTTPException(410, "link expired")

    background_tasks.add_task(increment_click, db, short_code)
    return RedirectResponse(url.long_url, status_code=301)

def increment_click(db: Session, short_code: str):
    url = db.get(URL, short_code)
    if url:
        url.click_count += 1
        db.commit()

Step 5: Rate limiting with Redis

import redis
r = redis.Redis(host="localhost", port=6379)

def rate_limit(request: Request):
    ip = request.client.host
    key = f"ratelimit:{ip}"
    count = r.incr(key)
    if count == 1:
        r.expire(key, 60)    # 60 sec window
    if count > 30:
        raise HTTPException(429, "rate limit exceeded")

Apply via dependency: dependencies=[Depends(rate_limit)] on the shorten endpoint.

Step 6: Stats endpoint

class UrlStats(BaseModel):
    short_code: str
    long_url: str
    click_count: int
    created_at: datetime
    expires_at: datetime | None

@app.get("/api/stats/{short_code}", response_model=UrlStats)
def stats(short_code: str, db: Session = Depends(get_db)):
    url = db.get(URL, short_code)
    if not url:
        raise HTTPException(404)
    return url

Deliverables

  • Working shortener with redirect + stats.
  • Custom slug support.
  • Expiration support.
  • Rate limiting per IP.
  • README + simple frontend (optional).

What you'll learn

  • Designing a small but real service.
  • RedirectResponse and HTTP status codes (301 vs 302 vs 308).
  • Background tasks for async-incrementing counters.
  • Redis as a rate-limiter store.
  • Pairs with the System Design course (walkthrough #1).

Stretch

  • Add user accounts; track who created each URL.
  • Analytics: clicks by day, top referrers.
  • QR code endpoint.
  • Custom domain support.

📊 100-Point Evaluation Rubric (Case 2)

Evaluation DimensionWeightTarget Standard (Pass ≥ 80%)
1. Functional Shortener40 ptsPOST /shorten (random + custom slugs, 409 on taken slug), GET /:code 307 redirects, click counts, expiry enforced.
2. Rate Limiting30 ptsRedis-backed per-IP/per-key limits with 429 responses; limits verified by test or script.
3. Docs + OpenAPI15 pts/docs schemas for shorten/stats responses; README documents slug rules and expiry semantics.
4. Code Quality15 ptssecrets-based codegen with collision retry, HttpUrl validation, no predictable sequences.

📤 Submission Instructions & Guidelines

  1. Deliverable Formats:
    • Option A (Recommended): Upload a single .zip archive containing your project directory: src/, tests/, Dockerfile (where applicable), and README.md with run instructions.
    • Option B: Upload your README.md and include your GitHub repository link in the Submission Notes box below.
  2. Pre-Submission Verification:
    • Run your full test suite (pytest) green; include the passing output in your README.
    • Exercise every endpoint once via /docs (Swagger UI) and paste one sample request/response per endpoint into your README.
    • Confirm no secrets are committed (.env git-ignored; secrets via environment only).
Checking your access…