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