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 Dimension | Weight | Target Standard (Pass ≥ 80%) |
|---|---|---|
| 1. Functional Shortener | 40 pts | POST /shorten (random + custom slugs, 409 on taken slug), GET /:code 307 redirects, click counts, expiry enforced. |
| 2. Rate Limiting | 30 pts | Redis-backed per-IP/per-key limits with 429 responses; limits verified by test or script. |
| 3. Docs + OpenAPI | 15 pts | /docs schemas for shorten/stats responses; README documents slug rules and expiry semantics. |
| 4. Code Quality | 15 pts | secrets-based codegen with collision retry, HttpUrl validation, no predictable sequences. |
📤 Submission Instructions & Guidelines
- Deliverable Formats:
- Option A (Recommended): Upload a single
.ziparchive containing your project directory:src/,tests/,Dockerfile(where applicable), andREADME.mdwith run instructions. - Option B: Upload your
README.mdand include your GitHub repository link in the Submission Notes box below.
- Option A (Recommended): Upload a single
- 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 (
.envgit-ignored; secrets via environment only).
- Run your full test suite (