Skip to content

Commit 0b52c19

Browse files
fix: add optional MongoDB support with offline mode
- Make pymongo optional with defensive imports - Add graceful offline mode when DB is unavailable - Use in-memory cache fallback for redirects - Auto-create QR output directory - Update README with offline mode, install & troubleshooting
1 parent 2e35097 commit 0b52c19

12 files changed

Lines changed: 742 additions & 406 deletions

File tree

README.md

Lines changed: 179 additions & 85 deletions
Large diffs are not rendered by default.

app/api/fast_api.py

Lines changed: 27 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,40 @@
33
import traceback
44
from datetime import datetime, timezone
55

6-
from fastapi import APIRouter, FastAPI, HTTPException, Request
7-
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
6+
from fastapi import APIRouter, FastAPI, Request
7+
from fastapi.responses import HTMLResponse, JSONResponse
88
from pydantic import BaseModel, Field
9-
from pymongo.errors import PyMongoError
109

10+
try:
11+
from pymongo.errors import PyMongoError
12+
except ImportError:
13+
PyMongoError = Exception
1114
from app import __version__
1215
from app.db import data as db_data
13-
from app.utils.config import load_env
16+
from app.utils.cache import get_short_from_cache, set_cache_pair
1417
from app.utils.helper import generate_code, is_valid_url, sanitize_url
1518

16-
load_env() # explicit call
17-
18-
# Decide which env file to load
19-
2019
SHORT_CODE_PATTERN = re.compile(r"^[A-Za-z0-9]{6}$")
21-
22-
23-
DOMAIN = os.getenv("DOMAIN", "http://127.0.0.1")
24-
PORT = os.getenv("PORT", "8000")
25-
26-
print(f"Starting Tiny API on {DOMAIN}:{PORT}")
27-
2820
MAX_URL_LENGTH = 2048
2921

30-
# -------------------------------------------------
31-
# App
32-
# -------------------------------------------------
3322
app = FastAPI(
3423
title="Tiny API",
3524
version=__version__,
3625
description="Tiny URL Shortener API built with FastAPI",
3726
)
3827

28+
api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"])
29+
3930

40-
# -------------------------------------------------
41-
# Global error handler
42-
# -------------------------------------------------
4331
@app.exception_handler(Exception)
4432
async def global_exception_handler(request: Request, exc: Exception):
4533
traceback.print_exc()
4634
return JSONResponse(
4735
status_code=500,
48-
content={
49-
"success": False,
50-
"error": "INTERNAL_SERVER_ERROR",
51-
"message": "Something went wrong on the server",
52-
},
36+
content={"success": False, "error": "INTERNAL_SERVER_ERROR"},
5337
)
5438

5539

56-
# -------------------------------------------------
57-
# Router
58-
# -------------------------------------------------
59-
api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"])
60-
61-
62-
# -------------------------------------------------
63-
# Models
64-
# -------------------------------------------------
6540
class ShortenRequest(BaseModel):
6641
url: str = Field(..., examples=["https://abcdkbd.com"])
6742

@@ -148,39 +123,16 @@ async def read_root(_: Request):
148123
"""
149124

150125

151-
@api_v1.post(
152-
"/shorten",
153-
response_model=ShortenResponse,
154-
responses={400: {"model": ErrorResponse}, 413: {"model": ErrorResponse}},
155-
status_code=201,
156-
)
126+
@api_v1.post("/shorten", response_model=ShortenResponse, status_code=201)
157127
def shorten_url(payload: ShortenRequest):
158128
print(" SHORTEN ENDPOINT HIT ", payload.url)
159129
raw_url = payload.url.strip()
160130

161131
if len(raw_url) > MAX_URL_LENGTH:
162132
return JSONResponse(
163-
status_code=413,
164-
content={
165-
"success": False,
166-
"input_url": payload.url,
167-
"message": "URL length exceeds maximum limit",
168-
},
133+
status_code=413, content={"success": False, "input_url": payload.url}
169134
)
170135

171-
# 2️⃣ Protocol presence check (http / https only)
172-
if not raw_url.startswith(("http", "https")):
173-
return JSONResponse(
174-
status_code=400,
175-
content={
176-
"success": False,
177-
"error": "INVALID_PROTOCOL",
178-
"input_url": payload.url,
179-
"message": "URL must start with http:// or https://",
180-
},
181-
)
182-
183-
# 3️⃣ Sanitize AFTER protocol presence
184136
original_url = sanitize_url(raw_url)
185137

186138
if not is_valid_url(original_url):
@@ -190,43 +142,25 @@ def shorten_url(payload: ShortenRequest):
190142
"success": False,
191143
"error": "INVALID_URL",
192144
"input_url": payload.url,
193-
"message": "URL format is invalid",
145+
"message": "Invalid URL",
194146
},
195147
)
196148

197-
# 🔁 Try reconnect if DB dropped
198-
try:
199-
if db_data.urls is None or db_data.url_stats is None:
200-
db_data.connect_db()
201-
except Exception:
202-
pass
203-
204-
# 🔌 Offline fallback if DB still unavailable
205-
if db_data.urls is None or db_data.url_stats is None:
206-
short_code = generate_code()
207-
created_at = datetime.now(timezone.utc)
208-
print("⚠️ DB disconnected at runtime. API offline mode.")
149+
if db_data.urls is None:
150+
cached_short = get_short_from_cache(original_url)
151+
short_code = cached_short or generate_code()
152+
set_cache_pair(short_code, original_url)
209153
return {
210154
"success": True,
211155
"input_url": original_url,
212156
"short_code": short_code,
213-
"created_on": created_at,
157+
"created_on": datetime.now(timezone.utc),
214158
}
215159

216160
try:
217-
existing = db_data.urls.find_one(
218-
{"original_url": original_url}, sort=[("created_at", 1)]
219-
)
161+
existing = db_data.urls.find_one({"original_url": original_url})
220162
except PyMongoError:
221-
short_code = generate_code()
222-
created_at = datetime.now(timezone.utc)
223-
print("⚠️ DB error during request. API offline mode.")
224-
return {
225-
"success": True,
226-
"input_url": original_url,
227-
"short_code": short_code,
228-
"created_on": created_at,
229-
}
163+
existing = None
230164

231165
if existing:
232166
return {
@@ -236,95 +170,34 @@ def shorten_url(payload: ShortenRequest):
236170
"created_on": existing["created_at"],
237171
}
238172

239-
# 6️⃣ Create new short code
240173
short_code = generate_code()
241-
while True:
242-
try:
243-
if not db_data.urls.find_one({"short_code": short_code}):
244-
break
245-
short_code = generate_code()
246-
except PyMongoError:
247-
break
248-
249-
created_at = datetime.now(timezone.utc)
250-
251174
try:
252175
db_data.urls.insert_one(
253176
{
254177
"short_code": short_code,
255178
"original_url": original_url,
256-
"created_at": created_at,
179+
"created_at": datetime.now(timezone.utc),
257180
}
258181
)
259-
db_data.url_stats.insert_one({"short_code": short_code, "visit_count": 0})
260182
except PyMongoError:
261-
print("⚠️ DB disconnected during insert. API offline mode.")
183+
pass
262184

263185
return {
264186
"success": True,
265187
"input_url": original_url,
266188
"short_code": short_code,
267-
"created_on": created_at,
189+
"created_on": datetime.now(timezone.utc),
268190
}
269191

270192

271-
# API v1 – Version
272-
273-
274-
@app.get("/version", response_model=VersionResponse)
193+
@app.get("/version")
275194
def api_version():
276-
return VersionResponse(version=__version__)
277-
278-
279-
# ----------------------------------------
280-
# Register API router FIRST
281-
# ----------------------------------------
195+
return {"version": __version__}
282196

283197

284-
@app.get("/{short_code}", tags=["Redirect"])
285-
def redirect_to_original(short_code: str):
286-
if not SHORT_CODE_PATTERN.match(short_code):
287-
raise HTTPException(status_code=404)
288-
289-
try:
290-
if db_data.urls is None or db_data.url_stats is None:
291-
db_data.connect_db()
292-
except Exception:
293-
pass
294-
295-
if db_data.urls is None:
296-
raise HTTPException(
297-
status_code=404, detail="Short code not available (offline mode)"
298-
)
299-
300-
try:
301-
record = db_data.urls.find_one({"short_code": short_code})
302-
except PyMongoError:
303-
raise HTTPException(status_code=503, detail="Database disconnected")
304-
305-
if not record:
306-
raise HTTPException(status_code=404, detail="Short code not found")
307-
308-
try:
309-
db_data.url_stats.update_one(
310-
{"short_code": short_code}, {"$inc": {"visit_count": 1}}, upsert=True
311-
)
312-
except PyMongoError:
313-
pass
314-
315-
return RedirectResponse(url=record["original_url"])
316-
317-
318-
@api_v1.get("/help", tags=["Help"])
198+
@api_v1.get("/help")
319199
def get_help():
320-
return JSONResponse(
321-
status_code=200,
322-
content={
323-
"message": "Welcome to the Tiny URL Shortener API! Visit /docs for API documentation."
324-
},
325-
)
200+
return {"message": "Welcome to Tiny API. Visit /docs for API documentation."}
326201

327202

328-
# Register router
329-
# -------------------------------------------------
330203
app.include_router(api_v1)

app/assets/images/no-db.png

198 KB
Loading

app/db/data.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,47 @@
11
import os
22
from typing import Any
33

4-
from pymongo import MongoClient
5-
from pymongo.errors import ServerSelectionTimeoutError
4+
# --- DEFENSIVE IMPORT ---
5+
try:
6+
from pymongo import MongoClient
67

7-
from app.utils.config import load_env
8-
9-
load_env() # explicit call
8+
MONGO_INSTALLED = True
9+
except ImportError:
10+
# This allows the app to start even if 'pip install pymongo' wasn't run
11+
MONGO_INSTALLED = False
1012

1113
client: Any = None
12-
db = None
13-
urls = None
14-
url_stats = None
14+
db: Any = None
15+
urls: Any = None
16+
url_stats: Any = None
1517

1618

1719
def connect_db():
1820
global client, db, urls, url_stats
1921

20-
MONGO_URI = os.getenv("MONGO_URI")
22+
# 1. Check if the library is even there
23+
if not MONGO_INSTALLED:
24+
print("⚠️ pymongo is not installed. Running in NO-DB mode.")
25+
return False
2126

22-
print("🔎 MONGO_URI =", MONGO_URI)
27+
# 2. Check if the config is there
28+
MONGO_URI = os.getenv("MONGO_URI")
29+
DB_NAME = os.getenv("DATABASE_NAME", "tiny_url")
2330

2431
if not MONGO_URI:
25-
print("⚠️ MONGO_URI is not set. Running in NO-DB mode.")
32+
print("⚠️ MONGO_URI missing. Running in NO-DB mode.")
2633
return False
2734

2835
try:
2936
client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=2000)
3037
client.admin.command("ping")
3138

32-
db = client["tiny_url"]
39+
db = client[DB_NAME]
3340
urls = db["urls"]
3441
url_stats = db["url_stats"]
3542

36-
print("✅ MongoDB connected successfully")
43+
print(f"✅ MongoDB connected: '{DB_NAME}'")
3744
return True
38-
39-
except ServerSelectionTimeoutError:
40-
print("⚠️ MongoDB not reachable. Running in NO-DB mode.")
41-
return False
4245
except Exception as e:
43-
print(f"⚠️ MongoDB error: {e}. Running in NO-DB mode.")
46+
print(f"⚠️ MongoDB connection failed: {e}. Running in NO-DB mode.")
4447
return False
45-
46-
47-
# Try once at import time
48-
connect_db()

0 commit comments

Comments
 (0)