diff --git a/README.md b/README.md
index ff01fba..6c65895 100644
--- a/README.md
+++ b/README.md
@@ -268,6 +268,27 @@ pip install dist/*.whl
pip install --upgrade dist/*.whl
```
+`๐ก Endpoints`
+
+๐ฅ๏ธ UI Endpoints
+
+| Method | Path | Description |
+| ------ | --------------- | ------------------------------------ |
+| GET | `/` | Home page (URL shortener UI) |
+| GET | `/recent` | Shows recently shortened URLs |
+| GET | `/{short_code}` | Redirects to the original URL |
+| GET | `/debug/cache` | ๐ง Debug cache view (local/dev only) |
+
+๐ API Endpoints (v1)
+
+| Method | Path | Description |
+| ------ | ------------------- | -------------------------------- |
+| POST | `/api/v1/shorten` | Create a short URL |
+| GET | `/api/v1/version` | Get API version |
+| GET | `/api/v1/health` | Health check (DB + cache status) |
+| GET | `/api/_debug/cache` | ๐ง Debug cache view (dev only) |
+| GET | `/api/{short_code}` | Redirect to original URL |
+
## License
๐Docs
diff --git a/app/api/fast_api.py b/app/api/fast_api.py
index d16c37a..d8d5a99 100644
--- a/app/api/fast_api.py
+++ b/app/api/fast_api.py
@@ -1,40 +1,19 @@
-import os
-import re
import traceback
-from datetime import datetime, timezone
-from typing import TYPE_CHECKING
-
-from fastapi import APIRouter, FastAPI, Request
-from fastapi.responses import HTMLResponse, JSONResponse
-from pydantic import BaseModel, Field
-
-if TYPE_CHECKING:
- from pymongo.errors import PyMongoError
-else:
- try:
- from pymongo.errors import PyMongoError
- except ImportError:
-
- class PyMongoError(Exception):
- pass
-
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
from app import __version__
-from app.utils import db
-from app.utils.cache import get_short_from_cache, set_cache_pair
-from app.utils.helper import generate_code, is_valid_url, sanitize_url
-
-SHORT_CODE_PATTERN = re.compile(r"^[A-Za-z0-9]{6}$")
-MAX_URL_LENGTH = 2048
+from app.routes import api_router, ui_router
app = FastAPI(
title="Tiny API",
version=__version__,
description="Tiny URL Shortener API built with FastAPI",
+ docs_url="/docs",
+ redoc_url="/redoc",
+ openapi_url="/openapi.json",
)
-api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"])
-
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
@@ -45,167 +24,5 @@ async def global_exception_handler(request: Request, exc: Exception):
)
-class ShortenRequest(BaseModel):
- url: str = Field(..., examples=["https://abcdkbd.com"])
-
-
-class ShortenResponse(BaseModel):
- success: bool = True
- input_url: str
- short_code: str
- created_on: datetime
-
-
-class ErrorResponse(BaseModel):
- success: bool = False
- error: str
- input_url: str
- message: str
-
-
-class VersionResponse(BaseModel):
- version: str
-
-
-# -------------------------------------------------
-# Home
-# -------------------------------------------------
-@app.get("/", response_class=HTMLResponse, tags=["Home"])
-async def read_root(_: Request):
- return """
-
-
- ๐ tiny API ๐
-
-
-
-
-
-
- """
-
-
-@api_v1.post("/shorten", response_model=ShortenResponse, status_code=201)
-def shorten_url(payload: ShortenRequest):
- print(" SHORTEN ENDPOINT HIT ", payload.url)
- raw_url = payload.url.strip()
-
- if len(raw_url) > MAX_URL_LENGTH:
- return JSONResponse(
- status_code=413, content={"success": False, "input_url": payload.url}
- )
-
- original_url = sanitize_url(raw_url)
-
- if not is_valid_url(original_url):
- return JSONResponse(
- status_code=400,
- content={
- "success": False,
- "error": "INVALID_URL",
- "input_url": payload.url,
- "message": "Invalid URL",
- },
- )
-
- if db.collection is None:
- cached_short = get_short_from_cache(original_url)
- short_code = cached_short or generate_code()
- set_cache_pair(short_code, original_url)
- return {
- "success": True,
- "input_url": original_url,
- "short_code": short_code,
- "created_on": datetime.now(timezone.utc),
- }
-
- try:
- existing = db.collection.find_one({"original_url": original_url})
- except PyMongoError:
- existing = None
-
- if existing:
- return {
- "success": True,
- "input_url": original_url,
- "short_code": existing["short_code"],
- "created_on": existing["created_at"],
- }
-
- short_code = generate_code()
- try:
- db.collection.insert_one(
- {
- "short_code": short_code,
- "original_url": original_url,
- "created_at": datetime.now(timezone.utc),
- }
- )
- except PyMongoError:
- pass
-
- return {
- "success": True,
- "input_url": original_url,
- "short_code": short_code,
- "created_on": datetime.now(timezone.utc),
- }
-
-
-@app.get("/version")
-def api_version():
- return {"version": __version__}
-
-
-@api_v1.get("/help")
-def get_help():
- return {"message": "Welcome to Tiny API. Visit /docs for API documentation."}
-
-
-app.include_router(api_v1)
+app.include_router(api_router)
+app.include_router(ui_router)
diff --git a/app/main.py b/app/main.py
index 8f393ba..5af8621 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,58 +1,82 @@
+# app/main.py
from contextlib import asynccontextmanager
from pathlib import Path
-from typing import Optional
import logging
+import traceback
+import asyncio
-from fastapi import FastAPI, Form, Request, status
-from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, JSONResponse
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
-from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
-from app.api.fast_api import app as api_app
+from app.routes import ui_router
from app.utils import db
-from app.utils.cache import (
- get_from_cache,
- get_recent_from_cache,
- get_short_from_cache,
- rev_cache,
- set_cache_pair,
- url_cache,
-)
-from app.utils.config import DOMAIN, MAX_RECENT_URLS, SESSION_SECRET
-from app.utils.helper import (
- format_date,
- generate_code,
- is_valid_url,
- sanitize_url,
-)
-from app.utils.qr import generate_qr_with_logo
+from app.utils.cache import cleanup_expired
+from app.utils.config import SESSION_SECRET
# -----------------------------
-# Lifespan: env + DB connect ONCE
+# Background cache cleanup task
+# -----------------------------
+async def cache_health_check():
+ logger = logging.getLogger(__name__)
+ logger.info("๐งน Cache cleanup task started")
+
+ while True:
+ try:
+ cleanup_expired()
+ except Exception as e:
+ logger.error(f"Cache cleanup error: {e}")
+ await asyncio.sleep(5) # cleanup every 5 seconds
+
+
+# -----------------------------
+# Lifespan: env + DB connect ONCE (DB-optional)
# -----------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
logger = logging.getLogger(__name__)
- logger.info("Application startup: Connecting to database...")
- db.connect_db()
- db.start_health_check()
+ logger.info("Application startup: Initializing services...")
+
+ # DB init (optional)
+ db_ok = db.connect_db()
+ if db_ok:
+ db.start_health_check()
+ logger.info("๐ข MongoDB enabled")
+ else:
+ logger.warning("๐ก MongoDB disabled (cache-only mode)")
+
+ # Cache TTL cleanup
+ cache_task = asyncio.create_task(cache_health_check())
+ logger.info("๐งน Cache TTL cleanup enabled")
+
logger.info("Application startup complete")
-
yield
-
+
logger.info("Application shutdown: Cleaning up...")
- await db.stop_health_check()
-
- # Close MongoDB client gracefully
+
+ # Stop cache task
+ cache_task.cancel()
+ try:
+ await cache_task
+ except asyncio.CancelledError:
+ logger.info("๐งน Cache cleanup task stopped")
+
+ # Stop DB health check
+ try:
+ await db.stop_health_check()
+ except Exception as e:
+ logger.error(f"Error stopping health check: {str(e)}")
+
+ # Close Mongo client if exists
try:
if db.client is not None:
db.client.close()
logger.info("MongoDB client closed")
except Exception as e:
logger.error(f"Error closing MongoDB client: {str(e)}")
-
+
logger.info("Application shutdown complete")
@@ -63,218 +87,21 @@ async def lifespan(app: FastAPI):
STATIC_DIR = BASE_DIR / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
-templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
-
-
-def build_short_url(short_code: str, request_host_url: str) -> str:
- base_url = DOMAIN.rstrip("/")
- return f"{base_url}/{short_code}"
-
-
-@app.get("/", response_class=HTMLResponse)
-async def index(request: Request):
- session = request.session
-
- new_short_url = session.pop("new_short_url", None)
- qr_enabled = session.pop("qr_enabled", False)
- qr_type = session.pop("qr_type", "short")
- original_url = session.pop("original_url", None)
- short_code = session.pop("short_code", None)
- info_message = session.pop("info_message", None)
- error = session.pop("error", None)
-
- qr_image = None
- qr_data = None
- if qr_enabled and new_short_url and short_code:
- qr_data = new_short_url if qr_type == "short" else original_url
- qr_filename = f"{short_code}.png"
- qr_dir = STATIC_DIR / "qr"
- qr_dir.mkdir(parents=True, exist_ok=True)
- generate_qr_with_logo(qr_data, str(qr_dir / qr_filename))
- qr_image = f"/static/qr/{qr_filename}"
-
- all_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
- MAX_RECENT_URLS
- )
-
- return templates.TemplateResponse(
- "index.html",
- {
- "request": request,
- "urls": all_urls,
- "new_short_url": new_short_url,
- "qr_image": qr_image,
- "qr_data": qr_data,
- "qr_enabled": qr_enabled,
- "original_url": original_url,
- "error": error,
- "info_message": info_message,
- "db_available": db.get_collection() is not None,
- },
- )
-
-
-@app.post("/shorten", response_class=RedirectResponse)
-async def create_short_url(
- request: Request,
- original_url: str = Form(""),
- generate_qr: Optional[str] = Form(None),
- qr_type: str = Form("short"),
-) -> RedirectResponse:
- logger = logging.getLogger(__name__)
-
- session = request.session
- qr_enabled = bool(generate_qr)
- original_url = sanitize_url(original_url)
- # Basic validation (FastAPI can also handle this via Pydantic)
- if not original_url or not is_valid_url(original_url):
- session["error"] = "Please enter a valid URL."
- return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
-
- # 1. Try Cache First
- short_code: Optional[str] = get_short_from_cache(original_url)
-
- if not short_code:
- # 2. Try Database if connected
- if db.is_connected():
- existing = db.find_by_original_url(original_url)
- db_code = existing.get("short_code") if existing else None
- if isinstance(db_code, str):
- short_code = db_code
- set_cache_pair(short_code, original_url)
-
- # 3. Generate New if still None
- if not short_code:
- short_code = generate_code()
- set_cache_pair(short_code, original_url)
-
- # Only write to database if connected
- if db.is_connected():
- db.insert_url(short_code, original_url)
- else:
- logger.warning(f"Database not connected, URL {short_code} created in cache only")
- session["info_message"] = "URL created (database temporarily unavailable)"
-
- # --- TYPE GUARD FOR MYPY ---
- if not isinstance(short_code, str):
- session["error"] = "Internal server error: Code generation failed."
- return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
-
- # Mypy now knows short_code is strictly 'str'
- new_short_url = build_short_url(short_code, DOMAIN)
-
- session.update(
- {
- "new_short_url": new_short_url,
- "qr_enabled": qr_enabled,
- "qr_type": qr_type,
- "original_url": original_url,
- "short_code": short_code,
- }
- )
-
- return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
-
-
-@app.get("/recent", response_class=HTMLResponse)
-async def recent_urls(request: Request):
- recent_urls_list = db.get_recent_urls(
- MAX_RECENT_URLS
- ) or get_recent_from_cache(MAX_RECENT_URLS)
-
- normalized = []
- for item in recent_urls_list:
- normalized.append(
- {
- "short_code": item.get("short_code"),
- "original_url": item.get("original_url"),
- "created_at": item.get("created_at"),
- "visit_count": item.get("visit_count", 0),
- }
- )
-
- return templates.TemplateResponse(
- "recent.html",
- {
- "request": request,
- "urls": normalized,
- "format_date": format_date,
- },
+# -----------------------------
+# Global error handler
+# -----------------------------
+@app.exception_handler(Exception)
+async def global_exception_handler(request: Request, exc: Exception):
+ traceback.print_exc()
+ return JSONResponse(
+ status_code=500,
+ content={"success": False, "error": "INTERNAL_SERVER_ERROR"},
)
-@app.post("/delete/{short_code}")
-async def delete_url(request: Request, short_code: str):
- db.delete_by_short_code(short_code)
-
- cached = url_cache.pop(short_code, None)
- if cached:
- rev_cache.pop(cached.get("url"), None)
-
- return PlainTextResponse("", status_code=204)
-
-
-@app.get("/{short_code}")
-async def redirect_short(request: Request, short_code: str):
- logger = logging.getLogger(__name__)
- # Try cache first
- cached_url = get_from_cache(short_code)
- if cached_url:
- return RedirectResponse(cached_url)
-
- # Check if database is connected
- if not db.is_connected():
- logger.warning(f"Database not connected, cannot redirect {short_code}")
- return PlainTextResponse(
- "Service temporarily unavailable. Please try again later.",
- status_code=503,
- headers={"Retry-After": "30"}
- )
-
- # Try database
- doc = db.increment_visit(short_code)
- if doc:
- set_cache_pair(short_code, doc["original_url"])
- return RedirectResponse(doc["original_url"])
-
- return PlainTextResponse("Invalid or expired short URL", status_code=404)
-
-
-@app.get("/coming-soon", response_class=HTMLResponse)
-async def coming_soon(request: Request):
- return templates.TemplateResponse("coming-soon.html", {"request": request})
-
-
-@app.get("/health")
-async def health_check():
- """Health check endpoint showing database and cache status."""
- state = db.get_connection_state()
-
- response_data = {
- "database": state,
- "cache": {
- "enabled": True,
- "size": len(url_cache),
- }
- }
-
- status_code = 200 if state["connected"] else 503
- return JSONResponse(content=response_data, status_code=status_code)
-
-
-app.mount("/api", api_app)
-
-
-@app.get("/_debug/cache")
-async def debug_cache():
- return {
- "url_cache": url_cache,
- "rev_cache": rev_cache,
- "recent_from_cache": get_recent_from_cache(MAX_RECENT_URLS),
- "size": {
- "url_cache": len(url_cache),
- "rev_cache": len(rev_cache),
- },
- }
+# -----------------------------
+# Routers (UI + API)
+# -----------------------------
+app.include_router(ui_router) # UI routes at "/"
diff --git a/app/routes.py b/app/routes.py
new file mode 100644
index 0000000..4fc7ea2
--- /dev/null
+++ b/app/routes.py
@@ -0,0 +1,329 @@
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional
+from app.utils.cache import list_cache_clean, clear_cache
+from fastapi import APIRouter, Form, Request, status, HTTPException
+from fastapi.responses import (
+ HTMLResponse,
+ PlainTextResponse,
+ RedirectResponse,
+ JSONResponse,
+)
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel, Field
+
+from app import __version__
+from app.utils import db
+from app.utils.cache import (
+ get_from_cache,
+ get_recent_from_cache,
+ get_short_from_cache,
+ set_cache_pair,
+ url_cache,
+)
+from app.utils.config import DOMAIN, MAX_RECENT_URLS
+from app.utils.helper import generate_code, is_valid_url, sanitize_url, format_date
+from app.utils.qr import generate_qr_with_logo
+
+BASE_DIR = Path(__file__).resolve().parent
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+# Routers
+ui_router = APIRouter()
+api_router = APIRouter()
+api_v1 = APIRouter(prefix=os.getenv("API_VERSION", "/api/v1"), tags=["v1"])
+
+
+# ---------------- UI ROUTES ----------------
+
+
+@ui_router.get("/", response_class=HTMLResponse)
+async def index(request: Request):
+ session = request.session
+
+ new_short_url = session.pop("new_short_url", None)
+ qr_enabled = session.pop("qr_enabled", False)
+ qr_type = session.pop("qr_type", "short")
+ original_url = session.pop("original_url", None)
+ short_code = session.pop("short_code", None)
+ info_message = session.pop("info_message", None)
+ error = session.pop("error", None)
+
+ qr_image = None
+ qr_data = None
+
+ if qr_enabled and new_short_url and short_code:
+ qr_data = new_short_url if qr_type == "short" else original_url
+ qr_filename = f"{short_code}.png"
+ qr_dir = BASE_DIR / "static" / "qr"
+ qr_dir.mkdir(parents=True, exist_ok=True)
+ generate_qr_with_logo(qr_data, str(qr_dir / qr_filename))
+ qr_image = f"/static/qr/{qr_filename}"
+
+ recent_urls = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
+ MAX_RECENT_URLS
+ )
+
+ return templates.TemplateResponse(
+ "index.html",
+ {
+ "request": request,
+ "urls": recent_urls,
+ "new_short_url": new_short_url,
+ "qr_image": qr_image,
+ "qr_data": qr_data,
+ "qr_enabled": qr_enabled,
+ "original_url": original_url,
+ "error": error,
+ "info_message": info_message,
+ "db_available": db.get_collection() is not None,
+ },
+ )
+
+
+@ui_router.post("/shorten", response_class=RedirectResponse)
+async def create_short_url(
+ request: Request,
+ original_url: str = Form(""),
+ generate_qr: Optional[str] = Form(None),
+ qr_type: str = Form("short"),
+):
+ session = request.session
+ original_url = sanitize_url(original_url)
+
+ if not original_url or not is_valid_url(original_url):
+ session["error"] = "Please enter a valid URL."
+ return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
+
+ short_code: Optional[str] = get_short_from_cache(original_url)
+
+ if not short_code and db.is_connected():
+ existing = db.find_by_original_url(original_url)
+ db_code = (existing.get("short_code") if existing else None) or (
+ existing.get("code") if existing else None
+ )
+ if isinstance(db_code, str):
+ short_code = db_code
+ set_cache_pair(short_code, original_url)
+
+ if not short_code:
+ short_code = generate_code()
+ set_cache_pair(short_code, original_url)
+ if db.is_connected():
+ db.insert_url(short_code, original_url)
+
+ session.update(
+ {
+ "new_short_url": f"{DOMAIN.rstrip('/')}/{short_code}",
+ "short_code": short_code,
+ "qr_enabled": bool(generate_qr),
+ "qr_type": qr_type,
+ "original_url": original_url,
+ }
+ )
+
+ return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@ui_router.get("/recent", response_class=HTMLResponse)
+async def recent_urls(request: Request):
+ recent_urls_list = db.get_recent_urls(MAX_RECENT_URLS) or get_recent_from_cache(
+ MAX_RECENT_URLS
+ )
+
+ return templates.TemplateResponse(
+ "recent.html",
+ {"request": request, "urls": recent_urls_list, "format_date": format_date},
+ )
+
+
+@ui_router.get("/cache/list")
+def cache_list_ui():
+ return list_cache_clean()
+
+
+@ui_router.post("/cache/clean")
+def cache_clean_ui(key: str):
+ """
+ key=CLEAR_ALL -> force delete everything from cache
+ key=FORCE_ONE -> force delete one entry (requires short_code or original_url)
+ """
+ if key == "CLEAR_ALL":
+ clear_cache() # ๐ฅ force wipe all cache
+ return {
+ "status": "cleared",
+ "strategy": "FORCE_FULL_RESET",
+ **list_cache_clean(),
+ }
+
+ raise HTTPException(400, "Invalid key. Use key=CLEAR_ALL")
+
+
+@ui_router.get("/{short_code}")
+def redirect_short_ui(short_code: str):
+ cached_url = get_from_cache(short_code)
+ if cached_url:
+ return RedirectResponse(cached_url)
+
+ if db.is_connected():
+ doc = db.increment_visit(short_code)
+ if doc and doc.get("original_url"):
+ set_cache_pair(short_code, doc["original_url"])
+ return RedirectResponse(doc["original_url"])
+
+ recent_db = db.get_recent_urls(MAX_RECENT_URLS)
+ for item in recent_db or []:
+ code = item.get("short_code") or item.get("code")
+ if code == short_code:
+ original_url = item.get("original_url")
+ if original_url:
+ set_cache_pair(short_code, original_url)
+ return RedirectResponse(original_url)
+
+ recent_cache = get_recent_from_cache(MAX_RECENT_URLS)
+ for item in recent_cache or []:
+ code = item.get("short_code") or item.get("code")
+ if code == short_code:
+ original_url = item.get("original_url")
+ if original_url:
+ set_cache_pair(short_code, original_url)
+ return RedirectResponse(original_url)
+
+ return PlainTextResponse("Invalid short URL", status_code=404)
+
+
+@ui_router.delete("/recent/{short_code}")
+def delete_recent_api(short_code: str):
+ """
+ Delete a short URL from recent list (cache-first, DB optional).
+ UI should never fail if DB is down.
+ """
+
+ # 1๏ธโฃ Remove from cache (source of truth for UI)
+ recent = get_recent_from_cache(MAX_RECENT_URLS)
+ removed_from_cache = False
+
+ for i, item in enumerate(recent or []):
+ code = item.get("short_code") or item.get("code")
+ if code == short_code:
+ recent.pop(i)
+ removed_from_cache = True
+ break
+
+ # 2๏ธโฃ Best-effort DB delete
+ db_deleted = False
+ if db.is_connected():
+ db_deleted = db.delete_by_short_code(short_code)
+
+ # 3๏ธโฃ Always succeed for UI
+ return {
+ "success": True,
+ "removed_from_cache": removed_from_cache,
+ "db_deleted": bool(db_deleted),
+ }
+
+
+# ---------------- API ROUTES ----------------
+
+
+@api_router.get("/", response_class=HTMLResponse, tags=["Home"])
+async def read_root(_: Request):
+ return """
+
+
+ ๐ tiny API ๐
+
+
+
+
+
+
+ """
+
+
+@api_router.get("/version")
+def api_version():
+ return {"version": __version__}
+
+
+class ShortenRequest(BaseModel):
+ url: str = Field(..., examples=["https://abcdkbd.com"])
+
+
+@api_v1.post("/shorten")
+def shorten_api(payload: ShortenRequest):
+ original_url = sanitize_url(payload.url)
+ if not is_valid_url(original_url):
+ return JSONResponse(status_code=400, content={"error": "INVALID_URL"})
+
+ short_code = get_short_from_cache(original_url)
+ if not short_code:
+ short_code = generate_code()
+ set_cache_pair(short_code, original_url)
+ if db.is_connected():
+ db.insert_url(short_code, original_url)
+
+ return {
+ "success": True,
+ "input_url": original_url,
+ "short_code": short_code,
+ "created_on": datetime.now(timezone.utc),
+ }
+
+
+@api_router.get("/health")
+def health():
+ return {
+ "db": db.get_connection_state(),
+ "cache_size": len(url_cache),
+ }
+
+
+api_router.include_router(api_v1)
diff --git a/app/utils/cache.py b/app/utils/cache.py
index a6f1c6a..14027b4 100644
--- a/app/utils/cache.py
+++ b/app/utils/cache.py
@@ -26,6 +26,31 @@ def _now() -> float:
return time.time()
+# -----------------------
+# Core cache operations
+# -----------------------
+
+
+def _delete_pair_by_short_code(short_code: str) -> None:
+ """
+ Remove both sides of cache using short_code.
+ """
+ data = url_cache.pop(short_code, None)
+ if data:
+ original_url = data["url"]
+ rev_cache.pop(original_url, None)
+
+
+def _delete_pair_by_url(original_url: str) -> None:
+ """
+ Remove both sides of cache using original_url.
+ """
+ data = rev_cache.pop(original_url, None)
+ if data:
+ short_code = data["short_code"]
+ url_cache.pop(short_code, None)
+
+
def get_from_cache(short_code: str) -> str | None:
data = url_cache.get(short_code)
@@ -33,7 +58,7 @@ def get_from_cache(short_code: str) -> str | None:
return None
if data["expires_at"] < _now():
- url_cache.pop(short_code, None)
+ _delete_pair_by_short_code(short_code)
return None
return data["url"]
@@ -46,12 +71,11 @@ def get_short_from_cache(original_url: str) -> str | None:
return None
if data["expires_at"] < _now():
- rev_cache.pop(original_url, None)
+ _delete_pair_by_url(original_url)
return None
# Touch for recent tracking
data["last_accessed"] = _now()
-
return data["short_code"]
@@ -79,24 +103,31 @@ def clear_cache() -> None:
rev_cache.clear()
+# -----------------------
+# TTL Cleanup (ACTIVE)
+# -----------------------
+
+
def cleanup_expired() -> None:
"""
- Optional: Manually remove expired cache entries.
- Can be called periodically (cron/background task).
+ Actively remove expired cache entries from both caches.
+ Safe to run periodically in background task.
"""
now = _now()
expired_short_codes = [
key for key, value in url_cache.items() if value["expires_at"] < now
]
- for key in expired_short_codes:
- url_cache.pop(key, None)
+
+ for short_code in expired_short_codes:
+ _delete_pair_by_short_code(short_code)
expired_urls = [
key for key, value in rev_cache.items() if value["expires_at"] < now
]
- for key in expired_urls:
- rev_cache.pop(key, None)
+
+ for original_url in expired_urls:
+ _delete_pair_by_url(original_url)
# -----------------------
@@ -106,22 +137,54 @@ def cleanup_expired() -> None:
def get_recent_from_cache(limit: int = MAX_RECENT_URLS) -> list[dict]:
"""
- Returns recent URLs based on cache activity (no duplicates, TTL-aware).
- Shape matches DB docs.
+ Returns recent URLs based on cache activity (TTL-aware, no duplicates).
"""
now = _now()
- items = [
+ valid_items = [
{
"short_code": data["short_code"],
"original_url": original_url,
+ "last_accessed": data["last_accessed"],
}
for original_url, data in rev_cache.items()
if data["expires_at"] >= now
]
- items.sort(
- key=lambda x: rev_cache[x["original_url"]]["last_accessed"], reverse=True
- )
+ valid_items.sort(key=lambda x: x["last_accessed"], reverse=True)
- return items[:limit]
+ return [
+ {
+ "short_code": item["short_code"],
+ "original_url": item["original_url"],
+ }
+ for item in valid_items[:limit]
+ ]
+
+
+# -----------------------
+# Debug / Introspection
+# -----------------------
+
+
+def list_cache_clean() -> dict:
+ """
+ Clean UI-friendly cache view (TTL-aware, no debug noise).
+ """
+ now = _now()
+
+ items = [
+ {
+ "short_code": data["short_code"],
+ "original_url": original_url,
+ }
+ for original_url, data in rev_cache.items()
+ if data["expires_at"] >= now
+ ]
+
+ return {
+ "count": len(items),
+ "items": items,
+ "MAX_RECENT_URLS": MAX_RECENT_URLS,
+ "CACHE_TTL": CACHE_TTL,
+ }
diff --git a/app/utils/config.py b/app/utils/config.py
index af78124..7bd3cec 100644
--- a/app/utils/config.py
+++ b/app/utils/config.py
@@ -1,14 +1,15 @@
-
import os
+
# -------------------------
# Helpers
# -------------------------
-from app.utils.config_env import load_env # noqa: F401
+from app.utils.config_env import load_env # noqa: F401
load_env()
+
def _get_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, default))
diff --git a/app/utils/db.py b/app/utils/db.py
index be7ed0c..6d45537 100644
--- a/app/utils/db.py
+++ b/app/utils/db.py
@@ -10,7 +10,6 @@
MONGO_INSTALLED = True
except ImportError:
MongoClient: Any = None # type: ignore
- Collection: Any # type: ignore
PyMongoError = Exception # type: ignore
MONGO_INSTALLED = False
@@ -31,13 +30,13 @@
health_check_task: Any = None
-def connect_db(max_retries: Optional[int] = None) -> bool:
+def connect_db(max_retries: int = 1) -> bool:
"""
Connect to MongoDB with retry logic and exponential backoff.
-
+
Args:
max_retries: Maximum number of retry attempts (defaults to config value)
-
+
Returns:
True if connection successful, False otherwise
"""
@@ -49,90 +48,72 @@ def connect_db(max_retries: Optional[int] = None) -> bool:
connection_error = "PyMongo not installed"
return False
+ if not MONGO_URI:
+ logger.warning("โ ๏ธ MONGO_URI not set. Running in NO-DB mode.")
+ connection_state = "FAILED"
+ connection_error = "MONGO_URI missing"
+ return False
+
from app.utils.config import (
- MONGO_MAX_RETRIES,
- MONGO_INITIAL_RETRY_DELAY,
- MONGO_MAX_RETRY_DELAY,
MONGO_TIMEOUT_MS,
MONGO_SOCKET_TIMEOUT_MS,
MONGO_MIN_POOL_SIZE,
MONGO_MAX_POOL_SIZE,
)
- import time
- if max_retries is None:
- max_retries = MONGO_MAX_RETRIES
+ connection_state = "CONNECTING"
+ last_connection_attempt = datetime.utcnow()
- retry_delay = MONGO_INITIAL_RETRY_DELAY
+ try:
+ new_client: Any = MongoClient(
+ MONGO_URI,
+ serverSelectionTimeoutMS=MONGO_TIMEOUT_MS,
+ socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS,
+ minPoolSize=MONGO_MIN_POOL_SIZE,
+ maxPoolSize=MONGO_MAX_POOL_SIZE,
+ )
- for attempt in range(1, max_retries + 1):
- connection_state = "CONNECTING"
- last_connection_attempt = datetime.utcnow()
-
- logger.info(f"Attempting to connect to MongoDB (attempt {attempt}/{max_retries})...")
+ new_client.admin.command("ping")
- try:
- # Create MongoClient with timeout and pool settings
- new_client: Any = MongoClient(
- MONGO_URI,
- serverSelectionTimeoutMS=MONGO_TIMEOUT_MS,
- socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS,
- minPoolSize=MONGO_MIN_POOL_SIZE,
- maxPoolSize=MONGO_MAX_POOL_SIZE,
- )
-
- # Validate connection with ping
- new_client.admin.command("ping")
-
- # Connection successful
- client = new_client
- db = new_client[MONGO_DB_NAME]
- collection = db[MONGO_COLLECTION]
- connection_state = "CONNECTED"
- connection_error = None
-
- logger.info("Successfully connected to MongoDB")
- return True
-
- except Exception as e:
- error_msg = f"Connection attempt {attempt} failed: {str(e)}"
- logger.warning(error_msg)
- connection_error = str(e)
-
- if attempt < max_retries:
- logger.info(f"Retrying in {retry_delay:.1f} seconds...")
- time.sleep(retry_delay)
- # Exponential backoff: double delay, cap at max
- retry_delay = min(retry_delay * 2, MONGO_MAX_RETRY_DELAY)
- else:
- logger.error(f"Failed to connect after {max_retries} attempts")
- connection_state = "FAILED"
- client = db = collection = None
-
- return False
-
-
-def get_collection() -> Optional[dict[str, Any]]:
+ client = new_client
+ db = new_client[MONGO_DB_NAME]
+ collection = db[MONGO_COLLECTION]
+
+ connection_state = "CONNECTED"
+ connection_error = None
+ logger.info("โ
MongoDB connected")
+ return True
+
+ except Exception as e:
+ logger.warning(f"โ ๏ธ MongoDB not reachable. Running in NO-DB mode: {e}")
+ connection_state = "FAILED"
+ connection_error = str(e)
+ client = db = collection = None
+ return False
+
+
+def get_collection() -> Optional[Any]:
return collection
+def is_connected() -> bool:
+ return connection_state == "CONNECTED" and collection is not None
+
+
def get_connection_state() -> dict[str, Any]:
"""Return current connection state information."""
return {
"state": connection_state,
- "last_attempt": last_connection_attempt.isoformat() if last_connection_attempt else None,
+ "last_attempt": (
+ last_connection_attempt.isoformat() if last_connection_attempt else None
+ ),
"error": connection_error,
"connected": is_connected(),
}
-def is_connected() -> bool:
- """Check if database is currently connected."""
- return connection_state == "CONNECTED" and collection is not None
-
-
# ------------------------
-# DB Operations
+# DB Operations (NO-OP SAFE)
# ------------------------
@@ -143,61 +124,50 @@ def find_by_original_url(original_url: str) -> Optional[dict]:
try:
return collection.find_one({"original_url": original_url})
except PyMongoError as e:
- logger.error(f"Error finding URL: {str(e)}")
- global connection_state, connection_error
- connection_state = "FAILED"
- connection_error = str(e)
+ logger.error(f"DB error (find_by_original_url): {e}")
+ _mark_failed(e)
return None
def insert_url(short_code: str, original_url: str) -> bool:
if not is_connected():
- logger.warning("Database not connected, cannot insert URL")
return False
try:
collection.insert_one(
{
"short_code": short_code,
"original_url": original_url,
- "created_at": __import__("datetime").datetime.utcnow(),
+ "created_at": datetime.utcnow(),
"visit_count": 0,
}
)
return True
except PyMongoError as e:
- logger.error(f"Error inserting URL: {str(e)}")
- global connection_state, connection_error
- connection_state = "FAILED"
- connection_error = str(e)
+ logger.error(f"DB error (insert_url): {e}")
+ _mark_failed(e)
return False
def delete_by_short_code(short_code: str) -> bool:
if not is_connected():
- logger.warning("Database not connected, cannot delete URL")
return False
try:
collection.delete_one({"short_code": short_code})
return True
except PyMongoError as e:
- logger.error(f"Error deleting URL: {str(e)}")
- global connection_state, connection_error
- connection_state = "FAILED"
- connection_error = str(e)
+ logger.error(f"DB error (delete_by_short_code): {e}")
+ _mark_failed(e)
return False
def get_recent_urls(limit: int = 10) -> list[dict]:
if not is_connected():
- logger.warning("Database not connected, cannot get recent URLs")
return []
try:
return list(collection.find().sort("created_at", -1).limit(limit))
except PyMongoError as e:
- logger.error(f"Error getting recent URLs: {str(e)}")
- global connection_state, connection_error
- connection_state = "FAILED"
- connection_error = str(e)
+ logger.error(f"DB error (get_recent_urls): {e}")
+ _mark_failed(e)
return []
@@ -212,48 +182,44 @@ def increment_visit(short_code: str) -> Optional[dict]:
return_document=True,
)
except PyMongoError as e:
- logger.error(f"Error incrementing visit: {str(e)}")
- global connection_state, connection_error
- connection_state = "FAILED"
- connection_error = str(e)
+ logger.error(f"DB error (increment_visit): {e}")
+ _mark_failed(e)
return None
+def _mark_failed(e: Exception) -> None:
+ global connection_state, connection_error, client, db, collection
+ connection_state = "FAILED"
+ connection_error = str(e)
+ client = db = collection = None
+
+
# ------------------------
-# Health Check
+# Health Check (Background reconnect)
# ------------------------
async def health_check_loop() -> None:
- """Background task that periodically checks database connection health."""
- global connection_state, connection_error
-
from app.utils.config import HEALTH_CHECK_INTERVAL_SECONDS
-
- logger.info("Health check loop started")
-
+
+ logger.info("๐ซ DB health check started")
+
try:
while True:
await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS)
-
- logger.debug("Running health check...")
-
- # If disconnected, try to reconnect
+
if not is_connected():
- logger.info("Database disconnected, attempting reconnection...")
+ logger.info("๐ DB disconnected. Retrying connection...")
connect_db()
continue
-
- # Validate active connection with ping
+
try:
- if client is not None:
+ if client:
client.admin.command("ping")
- logger.debug("Health check passed")
except Exception as e:
- logger.error(f"Health check failed: {str(e)}")
- connection_state = "FAILED"
- connection_error = str(e)
-
+ logger.error(f"โ Health check failed: {e}")
+ _mark_failed(e)
+
except asyncio.CancelledError:
logger.info("Health check loop cancelled")
raise
@@ -262,7 +228,7 @@ async def health_check_loop() -> None:
def start_health_check() -> Any:
"""Start the background health check task."""
global health_check_task
-
+
health_check_task = asyncio.create_task(health_check_loop())
logger.info("Health check task started")
return health_check_task
@@ -271,7 +237,7 @@ def start_health_check() -> Any:
async def stop_health_check() -> None:
"""Stop the background health check task."""
global health_check_task
-
+
if health_check_task is not None:
logger.info("Stopping health check task...")
health_check_task.cancel()
diff --git a/docs/run_with_curl.md b/docs/run_with_curl.md
index e7d6a6f..0202b9b 100644
--- a/docs/run_with_curl.md
+++ b/docs/run_with_curl.md
@@ -55,14 +55,37 @@ in request folder Create a file named input.json in the project root:
```#
$data = Get-Content .\request\urls.json -Raw | ConvertFrom-Json
-foreach ($item in $data) {
- $body = @{ url = $item.url } | ConvertTo-Json
+Write-Host "๐ Processing URLs..."
- Invoke-RestMethod `
- -Uri "http://127.0.0.1:8001/api/v1/shorten" `
- -Method POST `
- -ContentType "application/json" `
- -Body $body
+foreach ($item in $data) {
+ if (-not $item.url) {
+ Write-Host "โ Skipping invalid entry (missing url field)"
+ continue
+ }
+
+ $body = @{ url = $item.url } | ConvertTo-Json -Depth 3
+
+ try {
+ $response = Invoke-RestMethod `
+ -Uri "http://127.0.0.1:8001/shorten" `
+ -Method POST `
+ -ContentType "application/json" `
+ -Body $body
+
+ Write-Host "โ
SUCCESS: $($item.url) -> $($response.short_code)"
+ }
+ catch {
+ $status = $_.Exception.Response.StatusCode.value__ 2>$null
+ if ($status -eq 400) {
+ Write-Host "โ ERROR: $($item.url) - Invalid URL"
+ }
+ elseif ($status -eq 404) {
+ Write-Host "โ ERROR: $($item.url) - API endpoint not found"
+ }
+ else {
+ Write-Host "โ ERROR: $($item.url) - Rejected by API"
+ }
+ }
}
diff --git a/requirements.txt b/requirements.txt
index bf81eea..25863a5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,7 +19,7 @@ pymongo==4.16.0 ; python_version >= "3.10" and python_version < "3.13"
python-dotenv==1.2.1 ; python_version >= "3.10" and python_version < "3.13"
python-multipart==0.0.22 ; python_version >= "3.10" and python_version < "3.13"
qrcode==8.2 ; python_version >= "3.10" and python_version < "3.13"
-redis==7.1.1 ; python_version >= "3.10" and python_version < "3.13"
+redis==7.2.0 ; python_version >= "3.10" and python_version < "3.13"
starlette==0.52.1 ; python_version >= "3.10" and python_version < "3.13"
typing-extensions==4.15.0 ; python_version >= "3.10" and python_version < "3.13"
typing-inspection==0.4.2 ; python_version >= "3.10" and python_version < "3.13"