GitHub Action
deploy: worker release from GitHub
8ff1b66
import logging
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.rate_limit import limiter
from app.core.worker_logging import (
LIVE_LOG_WHITELIST,
read_log_tail,
resolve_log_path,
setup_app_logging,
setup_structured_logger,
)
from app.db.mongodb import mongodb
from app.routers import analyze, games
from app.services.nlp_service import get_nlp_service
from app.services.steam_service import steam_service
# Konfiguracja logowania
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
Zarządza cyklem życia aplikacji.
Nawiązuje połączenie z MongoDB przy starcie
i zamyka je przy wyłączeniu.
"""
if not settings.mongodb_url:
raise RuntimeError(
"MONGODB_URL is not set. Please configure it in .env or environment variables."
)
await mongodb.connect()
setup_structured_logger("live")
setup_app_logging()
yield
await steam_service.close()
await mongodb.disconnect()
app = FastAPI(
title="SentimentStream API",
description="API do analizy sentymentu recenzji gier Steam w czasie rzeczywistym",
version="1.0.0",
lifespan=lifespan,
)
# Rate limiter
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type]
# Konfiguracja CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "Accept"],
)
# Security headers
app.add_middleware(SecurityHeadersMiddleware)
# Rejestracja routerów
app.include_router(analyze.router, prefix="/api", tags=["analyze"])
app.include_router(games.router, prefix="/api", tags=["games"])
@app.get("/api/logs")
async def get_logs(
request: Request,
lines: int = Query(default=100, ge=1, le=1000),
level: str | None = Query(default=None),
event: str | None = Query(default=None),
file: str = Query(default="live"),
):
"""Token-protected endpoint to read structured log tail."""
auth = request.headers.get("Authorization", "")
expected = settings.worker_trigger_token
if expected:
if not auth.startswith("Bearer ") or auth[7:] != expected:
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
log_path = resolve_log_path(file, LIVE_LOG_WHITELIST)
if log_path is None:
return JSONResponse(
status_code=400,
content={"detail": f"Unknown log file: '{file}'. Valid: {list(LIVE_LOG_WHITELIST.keys())}"},
)
entries = read_log_tail(log_path, lines=lines, level=level, event=event)
return {"entries": entries, "count": len(entries)}
@app.get("/health")
async def health_check() -> dict:
"""Endpoint sprawdzający stan aplikacji z rzeczywistą weryfikacją zależności."""
mongo_ok = False
if mongodb.client is not None:
try:
await mongodb.client.admin.command("ping")
mongo_ok = True
except Exception:
pass
nlp_svc = get_nlp_service()
model_ok = hasattr(nlp_svc, "classifier") and nlp_svc.classifier is not None
overall = "healthy" if (mongo_ok and model_ok) else "degraded"
return {
"status": overall,
"mongodb": "connected" if mongo_ok else "disconnected",
"model": "loaded" if model_ok else "not_loaded",
}
# Obsługa plików statycznych (Frontend) - tylko jeśli istnieją (np. w Dockerze)
# Ścieżka w kontenerze Docker będzie: /app/frontend/dist
# Lokalnie zazwyczaj nie istnieje (bo używamy vite dev server), więc pomijamy
static_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend", "dist")
if settings.app_mode != "api" and os.path.exists(static_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
# Catch-all dla SPA (React Router)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
if full_path.startswith("api"):
return {"error": "API route not found"}
file_path = os.path.join(static_dir, full_path)
if os.path.exists(file_path) and os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(os.path.join(static_dir, "index.html"))