Build a URL Shortener from Scratch — Part 3: API Endpoints with FastAPI

What We’re Building

We’ll create the two core API endpoints: POST /shorten to create short URLs and GET /{code} to redirect. We’ll wire everything together with FastAPI dependency injection.

Step 1: Pydantic Schemas

# app/schemas.py
from pydantic import BaseModel, HttpUrl, Field
from datetime import datetime

class URLCreate(BaseModel):
    url: HttpUrl  # Validates URL format automatically
    custom_code: str | None = Field(None, max_length=20, pattern=r'^[a-zA-Z0-9_-]+$')
    expires_in_days: int | None = Field(None, ge=1, le=365)

class URLResponse(BaseModel):
    short_code: str
    short_url: str
    original_url: str
    created_at: datetime
    expires_at: datetime | None

    class Config:
        from_attributes = True

class URLAnalytics(BaseModel):
    short_code: str
    original_url: str
    click_count: int
    created_at: datetime

    class Config:
        from_attributes = True

Step 2: Shortener Service

# app/services/shortener.py
from sqlalchemy.orm import Session
from app.models import URL
from app.utils.base62 import generate_short_code
from app.config import settings

class ShortenerService:
    def __init__(self, db: Session):
        self.db = db

    def create(self, original_url: str, custom_code: str | None = None,
               expires_in_days: int | None = None) -> URL:
        # Check custom code uniqueness
        if custom_code:
            existing = self.db.query(URL).filter(URL.short_code == custom_code).first()
            if existing:
                raise ValueError("Custom code already taken")

        # Create the URL record first to get an ID
        url = URL(original_url=original_url)
        self.db.add(url)
        self.db.flush()  # Get the ID without committing

        # Generate short code
        if custom_code:
            url.short_code = custom_code
        else:
            url.short_code = generate_short_code(url.id)

        # Set expiration
        if expires_in_days:
            from datetime import datetime, timedelta, timezone
            url.expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)

        self.db.commit()
        self.db.refresh(url)
        return url

    def get_by_code(self, code: str) -> URL | None:
        return self.db.query(URL).filter(URL.short_code == code).first()

Step 3: API Routes

# app/api/routes.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.schemas import URLCreate, URLResponse, URLAnalytics
from app.services.shortener import ShortenerService
from app.core.database import get_db
from app.config import settings

router = APIRouter()

@router.post("/shorten", response_model=URLResponse, status_code=201)
def shorten_url(body: URLCreate, db: Session = Depends(get_db)):
    service = ShortenerService(db)
    try:
        url = service.create(
            original_url=str(body.url),
            custom_code=body.custom_code,
            expires_in_days=body.expires_in_days
        )
        return {
            "short_code": url.short_code,
            "short_url": f"{settings.base_url}/{url.short_code}",
            "original_url": url.original_url,
            "created_at": url.created_at,
            "expires_at": url.expires_at
        }
    except ValueError as e:
        raise HTTPException(status_code=409, detail=str(e))

@router.get("/{code}")
def redirect_to_url(code: str, db: Session = Depends(get_db)):
    service = ShortenerService(db)
    url = service.get_by_code(code)

    if not url:
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Check expiration
    from datetime import datetime, timezone
    if url.expires_at and url.expires_at < datetime.now(timezone.utc):
        raise HTTPException(status_code=410, detail="Short URL has expired")

    # Increment click count
    url.click_count += 1
    db.commit()

    from fastapi.responses import RedirectResponse
    return RedirectResponse(url=url.original_url, status_code=307)

@router.get("/stats/{code}", response_model=URLAnalytics)
def get_url_stats(code: str, db: Session = Depends(get_db)):
    service = ShortenerService(db)
    url = service.get_by_code(code)
    if not url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return url

Step 4: Wire Up the App

# app/main.py
from fastapi import FastAPI
from app.api.routes import router
from app.core.database import engine
from app.models import Base

# Create tables (for development; use Alembic in production)
Base.metadata.create_all(bind=engine)

app = FastAPI(title="URL Shortener", version="1.0.0")

app.include_router(router)

Step 5: Configuration

# app/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = "postgresql://postgres:password@localhost:5432/url_shortener"
    base_url: str = "http://localhost:8000"
    redis_url: str = "redis://localhost:6379"

    class Config:
        env_file = ".env"

settings = Settings()

Verification

# Start the server
uvicorn app.main:app --reload

# Create a short URL
curl -X POST http://localhost:8000/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/very-long-url", "expires_in_days": 30}'

# Response:
# {
#   "short_code": "q0Z",
#   "short_url": "http://localhost:8000/q0Z",
#   "original_url": "https://example.com/very-long-url",
#   "created_at": "2026-03-01T10:00:00Z",
#   "expires_at": "2026-03-31T10:00:00Z"
# }

# Test redirect (follow the redirect with -L)
curl -L http://localhost:8000/q0Z
# Redirects to https://example.com/very-long-url

# Check stats
curl http://localhost:8000/stats/q0Z
# {"short_code":"q0Z","original_url":"...","click_count":1,...}

Summary

  • Pydantic schemas validate input and shape responses
  • Shortener service encapsulates business logic: code generation, uniqueness checks, expiration
  • /shorten endpoint accepts URLs, custom codes, and expiration
  • /{code} endpoint redirects with proper HTTP 307 and handles expired links
  • /stats/{code} endpoint returns click counts

← Part 2 | Part 4 →


Advertisement