Compare commits

...
35 changed files with 1113 additions and 210 deletions
+6
View File
@@ -0,0 +1,6 @@
venv
__pycache__
*.pyc
*.db
.git
.env
-1
View File
@@ -1 +0,0 @@
BASE_URL=http://localhost:8000
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/url_db
BASE_URL=http://localhost:9995
DEBUG=True
+43 -2
View File
@@ -1,3 +1,44 @@
app/__pycache__ # Environment variables
.env
.env.local
.env.*
# Keep the example file for setup instructions
!.env.example
# Virtual environments
venv/ venv/
test.txt .env/
# Python bytecode and cache
__pycache__/
*.pyc
*.pyo
*.pyd
.python-version
# Pytest cache
.pytest_cache/
# IDE/editor files
.vscode/
.idea/
*.swp
*.swo
# Docker-related files
*.log
docker-compose.override.yml
# Coverage reports
htmlcov/
.coverage
coverage.xml
# Logs
*.log
# Extras
test.txt
diff.txt
test.db
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 9995
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9995"]
+11
View File
@@ -0,0 +1,11 @@
run:
uvicorn app.main:app --reload --port 9995
test:
pytest --cov=app --cov-report=term-missing
docker:
docker compose up --build
lint:
ruff check .
+47 -15
View File
@@ -1,27 +1,59 @@
# URL-shortener # URL Shortener API
URL-shortener project to showcase python use
Production-ready URL Shortener built with FastAPI, PostgreSQL, Docker, and Alembic.
![Tests](https://img.shields.io/badge/tests-passing-brightgreen)
![Coverage](https://img.shields.io/badge/coverage-99%25-brightgreen)
![Python](https://img.shields.io/badge/python-3.12-blue)
## Techstack
FastAPI, PostgreSQL, SQLAlchemy 2.0, Alembic, Docker, Pytest and Pydantic v2
## Features
Shorten URLs, Redirect, Click tracking, Stats endpoint, Collision handling, 99%+ test coverage, Dockerized and Alembic migrations
## Local Setup
## Create venv
```bash ```bash
cd /opt/kjc/int/URL-shortener cd <your-repo-url>
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
``` ```
## Run locally Create .env, copy as this as template:
```bash
uvicorn app.main:app --reload
``` ```
.env.example
```
Run:
```bash
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 9995
```
Then check:<br> Then check:<br>
http://127.0.0.1:8000<br> http://localhost:9995<br>
http://127.0.0.1:8000/docs http://localhost:9995/docs
Test Short URL Endpoint:<br> Test Short URL Endpoint:<br>
http://127.0.0.1:8000/shorten http://localhost:9995/shorten
## Running a test ## Running Alembic revision
```bash ```bash
PYTHONPATH=./ pytest cd <your-repo-url>
export PYTHONPATH=$(pwd) alembic revision --autogenerate -m "create urls table"
pytest ```
```
## Running project in Docker container
```bash
cd <your-repo-url>
docker compose up --build
```
## Running tests in Docker container
```bash
cd <your-repo-url>
docker compose up --build
docker compose exec app pytest --cov=app --cov-report=term-missing
```
+118
View File
@@ -0,0 +1,118 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./test.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+81
View File
@@ -0,0 +1,81 @@
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
from app.db.base import Base
from app.core.config import settings
target_metadata = Base.metadata
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
config.set_main_option("sqlalchemy.url", settings.database_url)
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,28 @@
"""create urls table
Revision ID: 0132ba841ec5
Revises:
Create Date: 2026-03-02 09:13:50.764599
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = '0132ba841ec5'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+71
View File
@@ -0,0 +1,71 @@
# app/api/routes.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.url import (
URLCreate,
URLResponse,
URLStatsResponse,
MessageResponse,
)
from app.services import url_service
router = APIRouter()
@router.get("/", response_model=MessageResponse)
def home():
return {"message": "URL Shortener API"}
@router.post("/shorten", response_model=URLResponse)
def shorten_url(
request: URLCreate,
db: Session = Depends(get_db),
):
normalized_url = str(request.url).rstrip("/")
short_url = url_service.create_short_url(db, normalized_url)
return {"short_url": short_url}
@router.get("/health")
def health():
return {"message": "healthy"}
@router.get("/{short_code}")
def redirect_to_url(
short_code: str,
db: Session = Depends(get_db),
):
original_url = url_service.get_original_url(db, short_code)
if not original_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shortened URL not found",
)
url_service.increment_clicks(db, short_code)
return RedirectResponse(url=original_url)
@router.get("/stats/{short_code}", response_model=URLStatsResponse)
def get_url_stats(
short_code: str,
db: Session = Depends(get_db),
):
stats = url_service.get_stats(db, short_code)
if not stats:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="URL stats not found",
)
return stats
+38
View File
@@ -0,0 +1,38 @@
# app/core/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
app_name: str = "URL Shortener"
allowed_hosts: List[str] = ["*"]
database_url: str
# Server settings
host: str = "0.0.0.0"
port: int = 9995
base_url: str = "http://localhost:9995"
# Debug flag
debug: bool = True
# Optional future extensions
rate_limit_per_minute: int = 60
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
extra = "ignore"
# Cached singleton-style access
@lru_cache()
def get_settings() -> Settings:
return Settings()
# Global settings instance
settings = get_settings()
+6
View File
@@ -0,0 +1,6 @@
# app/db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+57
View File
@@ -0,0 +1,57 @@
# app/db/models.py
from sqlalchemy import String, Integer, DateTime, func, Index
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.db.base import Base
from typing import Optional
class URL(Base):
__tablename__ = "urls"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
index=True
)
short_code: Mapped[str] = mapped_column(
String(10),
unique=True,
nullable=False,
index=True
)
original_url: Mapped[str] = mapped_column(
String(2048),
nullable=False,
index=True
)
clicks: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False
)
last_accessed: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True
)
# Additional compound index for faster lookups
__table_args__ = (
Index("idx_short_original", "short_code", "original_url"),
)
def __repr__(self) -> str:
return (
f"<URL(id={self.id}, short_code='{self.short_code}', "
f"original_url='{self.original_url}', clicks={self.clicks})>"
)
+26
View File
@@ -0,0 +1,26 @@
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLAlchemy engine
engine = create_engine(
settings.database_url,
pool_pre_ping=True, # helps prevent stale connections
)
# Session factory
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
# Dependency for FastAPI
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+24 -112
View File
@@ -1,124 +1,36 @@
from fastapi import FastAPI, HTTPException # app/main.py
from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager
from app.utils import generate_short_code
from pydantic import BaseModel, HttpUrl
from pydantic_settings import BaseSettings
import sqlite3
import logging import logging
from fastapi import FastAPI
from contextlib import asynccontextmanager
from app.core.config import settings
from app.db.session import engine
from app.db.base import Base
from app.api.routes import router
class AppSettings(BaseSettings): logging.basicConfig(
base_url: str = "http://localhost:8000" level=logging.INFO,
database_url: str = "urls.db" format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
settings = AppSettings() # Load environment variables import os
from contextlib import asynccontextmanager
# Set up logging configuration
logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# Database connection setup
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
conn = sqlite3.connect(settings.database_url) if os.getenv("TESTING") != "1":
conn.execute(""" Base.metadata.create_all(bind=engine)
CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY,
short_code TEXT UNIQUE,
original_url TEXT NOT NULL,
clicks INTEGER DEFAULT 0
);
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code);
""")
conn.commit()
logger.info("Database initialized or already exists.")
yield yield
conn.close()
logger.info("Database connection closed.")
app = FastAPI(lifespan=lifespan) def create_app() -> FastAPI:
app = FastAPI(
def get_db_connection(): title=settings.app_name,
return sqlite3.connect(settings.database_url, check_same_thread=False) debug=settings.debug,
class URLRequest(BaseModel):
url: HttpUrl
@app.get("/")
def home():
logger.info("Home endpoint accessed.")
return {"message": "URL Shortener API"}
@app.post("/shorten")
def shorten_url(request: URLRequest):
conn = get_db_connection()
existing = conn.execute(
"SELECT short_code FROM urls WHERE original_url = ?",
(str(request.url),)
).fetchone()
if existing:
conn.close()
return {"short_url": f"{settings.base_url}/{existing[0]}"}
short_code = generate_short_code()
while conn.execute(
"SELECT 1 FROM urls WHERE short_code = ?",
(short_code,)
).fetchone():
short_code = generate_short_code()
conn.execute(
"INSERT INTO urls (short_code, original_url, clicks) VALUES (?, ?, 0)",
(short_code, str(request.url))
) )
conn.commit()
conn.close()
return {"short_url": f"{settings.base_url}/{short_code}"} app.include_router(router)
@app.get("/{short_code}") return app
def redirect_to_url(short_code: str):
conn = get_db_connection()
url_data = conn.execute(
"SELECT original_url FROM urls WHERE short_code = ?",
(short_code,)
).fetchone()
if url_data is None: app = create_app()
conn.close() app.router.lifespan_context = lifespan
raise HTTPException(status_code=404, detail="Shortened URL not found")
original_url = url_data[0]
conn.execute(
"UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?",
(short_code,)
)
conn.commit()
conn.close()
return RedirectResponse(url=original_url)
@app.get("/stats/{short_code}")
def get_url_stats(short_code: str):
logger.info(f"Stats request received for short_code: {short_code}")
conn = get_db_connection()
url_data = conn.execute("SELECT original_url, clicks FROM urls WHERE short_code = ?", (short_code,)).fetchone()
if url_data is None:
logger.warning(f"Stats not found for {short_code}.")
conn.close()
raise HTTPException(status_code=404, detail="URL stats not found")
original_url, clicks = url_data
conn.close()
logger.info(f"Returning stats for {original_url}. Total clicks: {clicks}")
return {"original_url": original_url, "clicks": clicks}
+24
View File
@@ -0,0 +1,24 @@
# app/schemas/url.py
from pydantic import BaseModel, HttpUrl
from datetime import datetime
from typing import Optional
class URLCreate(BaseModel):
url: HttpUrl
class URLResponse(BaseModel):
short_url: str
class URLStatsResponse(BaseModel):
original_url: str
clicks: int
created_at: datetime
last_accessed: Optional[datetime]
class MessageResponse(BaseModel):
message: str
+105
View File
@@ -0,0 +1,105 @@
# app/services/url_service.py
from sqlalchemy.orm import Session
from sqlalchemy import select
from datetime import datetime
from typing import Optional
from app.db.models import URL
from app.utils.short_code import generate_short_code
from app.core.config import settings
SHORT_CODE_LENGTH = 6
MAX_GENERATION_ATTEMPTS = 5
def create_short_url(db: Session, original_url: str) -> str:
"""
Create a shortened URL or return existing one if already present.
"""
# ✅ Normalize FIRST (critical fix)
normalized_url = original_url.rstrip("/")
# 1️⃣ Check if URL already exists
existing = db.execute(
select(URL).where(URL.original_url == normalized_url)
).scalar_one_or_none()
if existing:
return f"{settings.base_url}/{existing.short_code}"
# 2️⃣ Generate unique short code with collision handling
for _ in range(MAX_GENERATION_ATTEMPTS):
short_code = generate_short_code(SHORT_CODE_LENGTH)
collision = db.execute(
select(URL).where(URL.short_code == short_code)
).scalar_one_or_none()
if not collision:
new_url = URL(
short_code=short_code,
original_url=normalized_url, # ✅ store normalized version
)
db.add(new_url)
db.commit()
db.refresh(new_url)
return f"{settings.base_url}/{new_url.short_code}"
raise RuntimeError("Failed to generate unique short code")
def get_original_url(db: Session, short_code: str) -> Optional[str]:
"""
Retrieve original URL for redirection.
"""
url_entry = db.execute(
select(URL).where(URL.short_code == short_code)
).scalar_one_or_none()
if not url_entry:
return None
return url_entry.original_url
def increment_clicks(db: Session, short_code: str) -> bool:
"""
Increment click counter and update last_accessed timestamp.
"""
url_entry = db.execute(
select(URL).where(URL.short_code == short_code)
).scalar_one_or_none()
if not url_entry:
return False
url_entry.clicks += 1
url_entry.last_accessed = datetime.utcnow()
db.commit()
return True
def get_stats(db: Session, short_code: str) -> Optional[dict]:
"""
Return URL statistics.
"""
url_entry = db.execute(
select(URL).where(URL.short_code == short_code)
).scalar_one_or_none()
if not url_entry:
return None
return {
"original_url": url_entry.original_url,
"clicks": url_entry.clicks,
"created_at": url_entry.created_at,
"last_accessed": url_entry.last_accessed,
}
-7
View File
@@ -1,7 +0,0 @@
# app/settings.py
import os
# Example configuration
database_url = os.getenv("DATABASE_URL", "sqlite:///default.db")
debug = os.getenv("DEBUG", "True") == "True"
-66
View File
@@ -1,66 +0,0 @@
from fastapi.testclient import TestClient
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.main import app
from app import settings
import tempfile
import pytest
@pytest.fixture
def test_client():
with tempfile.NamedTemporaryFile() as tmp:
settings.database_url = tmp.name
with TestClient(app) as c:
yield c
@pytest.fixture
def use_temp_db():
with tempfile.NamedTemporaryFile() as tmp:
settings.database_url = tmp.name
yield
def test_home(test_client):
response = test_client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "URL Shortener API"}
def test_shorten_url(test_client):
response = test_client.post("/shorten", json={"url": "https://google.com"})
assert response.status_code == 200
data = response.json()
assert "short_url" in data
short_url = data["short_url"]
assert short_url.startswith("http://localhost:8000/")
def test_redirect(test_client):
response = test_client.post("/shorten", json={"url": "https://google.com"})
short_url = response.json()["short_url"]
code = short_url.split("/")[-1]
redirect = test_client.get(f"/{code}", follow_redirects=False)
assert redirect.status_code == 307
def test_duplicate_url_returns_same_code(test_client):
r1 = test_client.post("/shorten", json={"url": "https://example.com"})
r2 = test_client.post("/shorten", json={"url": "https://example.com"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["short_url"] == r2.json()["short_url"]
def test_stats_endpoint(test_client):
response = test_client.post("/shorten", json={"url": "https://stats-test.com"})
code = response.json()["short_url"].split("/")[-1]
test_client.get(f"/{code}", follow_redirects=False)
stats = test_client.get(f"/stats/{code}")
assert stats.status_code == 200
data = stats.json()
assert data["clicks"] == 1
def test_redirect_404(test_client):
response = test_client.get("/nonexistent", follow_redirects=False)
assert response.status_code == 404
+61
View File
@@ -0,0 +1,61 @@
# app/tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from app.main import app
from app.db.base import Base
from app.db.session import get_db
import os
os.environ["TESTING"] = "1"
# Use in-memory SQLite for tests
TEST_DATABASE_URL = "sqlite+pysqlite:///:memory:"
@pytest.fixture(scope="function")
def db_session():
engine = create_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db_session):
"""
Overrides get_db dependency to use test database.
"""
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+16
View File
@@ -0,0 +1,16 @@
from app.core.config import get_settings
def test_get_settings_returns_same_instance():
s1 = get_settings()
s2 = get_settings()
assert s1 is s2 # lru_cache should return same instance
def test_default_settings_values():
settings = get_settings()
assert settings.app_name == "URL Shortener"
assert settings.host == "0.0.0.0"
assert settings.port == 9995
assert settings.debug is True
assert isinstance(settings.allowed_hosts, list)
+32
View File
@@ -0,0 +1,32 @@
from app.db.models import URL
def test_url_model_defaults(db_session):
url = URL(
short_code="abc123",
original_url="https://example.com",
)
db_session.add(url)
db_session.commit()
db_session.refresh(url)
assert url.id is not None
assert url.clicks == 0
assert url.created_at is not None
assert url.last_accessed is None
def test_url_repr(db_session):
url = URL(
short_code="xyz789",
original_url="https://repr-test.com",
)
db_session.add(url)
db_session.commit()
db_session.refresh(url)
repr_output = repr(url)
assert "URL" in repr_output
assert "xyz789" in repr_output
assert "https://repr-test.com" in repr_output
+90
View File
@@ -0,0 +1,90 @@
# app/tests/test_routes.py
def test_home(client):
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "URL Shortener API"}
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"message": "healthy"}
def test_shorten_url(client):
response = client.post(
"/shorten",
json={"url": "https://google.com"},
)
assert response.status_code == 200
data = response.json()
assert "short_url" in data
assert data["short_url"].startswith("http://")
def test_shorten_invalid_url(client):
response = client.post(
"/shorten",
json={"url": "not-a-valid-url"},
)
assert response.status_code == 422 # Pydantic validation error
def test_duplicate_url_returns_same_code(client):
r1 = client.post("/shorten", json={"url": "https://example.com"})
r2 = client.post("/shorten", json={"url": "https://example.com"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["short_url"] == r2.json()["short_url"]
def test_redirect_success(client):
shorten = client.post(
"/shorten",
json={"url": "https://redirect-test.com"},
)
short_url = shorten.json()["short_url"]
code = short_url.split("/")[-1]
response = client.get(f"/{code}", follow_redirects=False)
assert response.status_code in (302, 307)
def test_redirect_404(client):
response = client.get("/nonexistent", follow_redirects=False)
assert response.status_code == 404
def test_stats_success(client):
shorten = client.post(
"/shorten",
json={"url": "https://stats-test.com"},
)
short_url = shorten.json()["short_url"]
code = short_url.split("/")[-1]
# Trigger one click
client.get(f"/{code}", follow_redirects=False)
stats = client.get(f"/stats/{code}")
assert stats.status_code == 200
data = stats.json()
assert data["original_url"] == "https://stats-test.com"
assert data["clicks"] == 1
assert "created_at" in data
def test_stats_404(client):
response = client.get("/stats/doesnotexist")
assert response.status_code == 404
+106
View File
@@ -0,0 +1,106 @@
import pytest
from unittest.mock import patch
from app.services import url_service
from app.db.models import URL
def test_create_short_url_creates_new(db_session):
short_url = url_service.create_short_url(
db_session,
"https://new-url.com",
)
assert short_url.startswith("http://")
entry = db_session.query(URL).first()
assert entry is not None
assert entry.original_url == "https://new-url.com"
def test_create_short_url_returns_existing(db_session):
first = url_service.create_short_url(
db_session,
"https://duplicate.com",
)
second = url_service.create_short_url(
db_session,
"https://duplicate.com",
)
assert first == second
assert db_session.query(URL).count() == 1
def test_create_short_url_collision_failure(db_session):
with patch(
"app.services.url_service.generate_short_code",
return_value="fixedcode",
):
# First insert works
url_service.create_short_url(db_session, "https://a.com")
# Force collision repeatedly
with pytest.raises(RuntimeError):
url_service.create_short_url(db_session, "https://b.com")
def test_get_original_url_success(db_session):
short_url = url_service.create_short_url(
db_session,
"https://lookup.com",
)
code = short_url.split("/")[-1]
original = url_service.get_original_url(db_session, code)
assert original == "https://lookup.com"
def test_get_original_url_not_found(db_session):
result = url_service.get_original_url(db_session, "missing")
assert result is None
def test_increment_clicks_success(db_session):
short_url = url_service.create_short_url(
db_session,
"https://click-test.com",
)
code = short_url.split("/")[-1]
success = url_service.increment_clicks(db_session, code)
assert success is True
entry = db_session.query(URL).first()
assert entry.clicks == 1
assert entry.last_accessed is not None
def test_increment_clicks_not_found(db_session):
result = url_service.increment_clicks(db_session, "missing")
assert result is False
def test_get_stats_success(db_session):
short_url = url_service.create_short_url(
db_session,
"https://stats.com",
)
code = short_url.split("/")[-1]
url_service.increment_clicks(db_session, code)
stats = url_service.get_stats(db_session, code)
assert stats is not None
assert stats["original_url"] == "https://stats.com"
assert stats["clicks"] == 1
assert stats["created_at"] is not None
def test_get_stats_not_found(db_session):
stats = url_service.get_stats(db_session, "missing")
assert stats is None
+19
View File
@@ -0,0 +1,19 @@
import string
from app.utils.short_code import generate_short_code
def test_generate_short_code_length():
code = generate_short_code(8)
assert len(code) == 8
def test_generate_short_code_is_alphanumeric():
code = generate_short_code(12)
allowed = string.ascii_letters + string.digits
for char in code:
assert char in allowed
def test_generate_short_code_randomness():
code1 = generate_short_code()
code2 = generate_short_code()
assert code1 != code2 # extremely unlikely to fail
-7
View File
@@ -1,7 +0,0 @@
import string
import random
def generate_short_code(length=6):
characters = string.ascii_letters + string.digits
return ''.join(random.choice(characters) for _ in range(length))
View File
+8
View File
@@ -0,0 +1,8 @@
# app/utils/short_code.py
import string
import random
def generate_short_code(length: int = 6) -> str:
"""Generate a random alphanumeric short code."""
chars = string.ascii_letters + string.digits
return ''.join(random.choices(chars, k=length))
+36
View File
@@ -0,0 +1,36 @@
services:
db:
image: postgres:16
container_name: url_shortener_db
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: url_db
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
container_name: url_shortener_app
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/url_db
BASE_URL: http://localhost:9995
ports:
- "9995:9995"
volumes:
- .:/app # Mount current directory to /app inside container
command: >
sh -c "alembic upgrade head &&
uvicorn app.main:app --host 0.0.0.0 --port 9995"
volumes:
postgres_data:
+6
View File
@@ -0,0 +1,6 @@
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.pytest.ini_options]
pythonpath = ["."]
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.124.4
uvicorn[standard]==0.33.0
sqlalchemy==2.0.47
psycopg2-binary==2.9.10
alembic==1.14.1
pydantic-settings==2.8.1
pytest==8.3.5
pytest-cov==5.0.0
ruff==0.15.4
httpx==0.24.1
BIN
View File
Binary file not shown.