diff --git a/.gitignore b/.gitignore index 8750bee..9aaeae7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ app/__pycache__ -venv/ \ No newline at end of file +venv/ +test.txt \ No newline at end of file diff --git a/README.md b/README.md index 2bde86f..dfe6b60 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ source venv/bin/activate ```bash uvicorn app.main:app --reload ``` -Then check: -http://127.0.0.1:8000 +Then check:
+http://127.0.0.1:8000
http://127.0.0.1:8000/docs -Test Short URL Endpoint +Test Short URL Endpoint:
http://127.0.0.1:8000/shorten ## Running a test diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py index 079aec0..744d739 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,18 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import RedirectResponse from contextlib import asynccontextmanager -from pydantic import BaseModel from app.utils import generate_short_code -from dotenv import load_dotenv -import os +from pydantic import BaseModel, HttpUrl +from pydantic_settings import BaseSettings import sqlite3 import logging +class AppSettings(BaseSettings): + base_url: str = "http://localhost:8000" + database_url: str = "urls.db" + +settings = AppSettings() # Load environment variables + # Set up logging configuration logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") @@ -16,9 +21,18 @@ logger = logging.getLogger(__name__) # Database connection setup @asynccontextmanager async def lifespan(app: FastAPI): - conn = sqlite3.connect('urls.db') - conn.execute('''CREATE TABLE IF NOT EXISTS urls - (id INTEGER PRIMARY KEY, short_code TEXT, original_url TEXT, clicks INTEGER)''') + conn = sqlite3.connect(settings.database_url) + conn.execute(""" + 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 @@ -28,11 +42,10 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) def get_db_connection(): - conn = sqlite3.connect('urls.db') - return conn + return sqlite3.connect(settings.database_url, check_same_thread=False) class URLRequest(BaseModel): - url: str + url: HttpUrl @app.get("/") def home(): @@ -41,36 +54,71 @@ def home(): @app.post("/shorten") def shorten_url(request: URLRequest): - logger.info(f"Request received to shorten URL: {request.url}") - short_code = generate_short_code() conn = get_db_connection() - while conn.execute("SELECT 1 FROM urls WHERE short_code = ?", (short_code,)).fetchone(): + 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 (?, ?, ?)", - (short_code, request.url, 0)) + conn.execute( + "INSERT INTO urls (short_code, original_url, clicks) VALUES (?, ?, 0)", + (short_code, str(request.url)) + ) conn.commit() conn.close() - short_url = f"{os.getenv('BASE_URL', 'http://localhost:8000')}/{short_code}" - logger.info(f"Shortened URL created: {short_url}") - return {"short_url": short_url} + return {"short_url": f"{settings.base_url}/{short_code}"} @app.get("/{short_code}") def redirect_to_url(short_code: str): - logger.info(f"Redirect 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() + url_data = conn.execute( + "SELECT original_url FROM urls WHERE short_code = ?", + (short_code,) + ).fetchone() if url_data is None: - logger.warning(f"Shortened URL for {short_code} not found.") + conn.close() raise HTTPException(status_code=404, detail="Shortened URL not found") - original_url, clicks = url_data - conn.execute("UPDATE urls SET clicks = ? WHERE short_code = ?", (clicks + 1, short_code)) + original_url = url_data[0] + + conn.execute( + "UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?", + (short_code,) + ) conn.commit() conn.close() - logger.info(f"Redirecting to {original_url}. Total clicks: {clicks + 1}") - return RedirectResponse(url=original_url) \ No newline at end of file + 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} \ No newline at end of file diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..3883334 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,7 @@ +# app/settings.py + +import os + +# Example configuration +database_url = os.getenv("DATABASE_URL", "sqlite:///default.db") +debug = os.getenv("DEBUG", "True") == "True" \ No newline at end of file diff --git a/app/test_main.py b/app/test_main.py index 637d1ca..9355bf4 100644 --- a/app/test_main.py +++ b/app/test_main.py @@ -3,20 +3,64 @@ 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 -client = TestClient(app) +@pytest.fixture +def test_client(): + with tempfile.NamedTemporaryFile() as tmp: + settings.database_url = tmp.name + with TestClient(app) as c: + yield c -def test_home(): - response = client.get("/") +@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(): - response = client.post("/shorten", json={"url": "https://google.com"}) +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/") \ No newline at end of file + 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 \ No newline at end of file diff --git a/urls.db b/urls.db index b82e8df..32dc3f9 100644 Binary files a/urls.db and b/urls.db differ