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 ๐ŸŒ™ - - - -
-

๐Ÿš€ tiny API

-

FastAPI backend for the Tiny URL shortener

- View API Documentation -
- - - """ - - -@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 ๐ŸŒ™ + + + +
+

๐Ÿš€ tiny API

+

FastAPI backend for the Tiny URL shortener

+ View API Documentation +
+ + + """ + + +@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"