from __future__ import annotations import asyncio from typing import Dict, Optional import httpx from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from sqlmodel import SQLModel, select from sqlalchemy.exc import IntegrityError from services.api.models import User from services.common.auth import create_token, decode_token, hash_password, verify_password from services.common.db import SessionLocal, engine from services.common.schemas import ( ActionRequest, AddAIRequest, CreateGameRequest, JoinGameRequest, TradeOfferRequest, TradeRespondRequest, ) from services.common.settings import settings app = FastAPI(title="Catan API Gateway") cors_origins = [origin.strip() for origin in settings.cors_origins.split(",") if origin.strip()] app.add_middleware( CORSMiddleware, allow_origins=cors_origins or ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) clients: Dict[str, httpx.AsyncClient] = {} ws_connections: Dict[str, Dict[WebSocket, str]] = {} ai_tasks: Dict[str, asyncio.Task] = {} @app.on_event("startup") async def _startup() -> None: SQLModel.metadata.create_all(engine) clients["game"] = httpx.AsyncClient(base_url=settings.game_service_url) clients["analytics"] = httpx.AsyncClient(base_url=settings.analytics_service_url) clients["ai"] = httpx.AsyncClient(base_url=settings.ai_service_url) @app.on_event("shutdown") async def _shutdown() -> None: for client in clients.values(): await client.aclose() def _get_current_user(request: Request) -> User: auth = request.headers.get("authorization", "") if not auth.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Missing token") token = auth.split(" ", 1)[1].strip() try: payload = decode_token(token) except Exception: raise HTTPException(status_code=401, detail="Invalid token") user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="Invalid token") with SessionLocal() as session: user = session.get(User, int(user_id)) if not user: raise HTTPException(status_code=401, detail="Unknown user") return user def _get_user_from_ws(websocket: WebSocket) -> User: token = websocket.query_params.get("token") if not token: raise HTTPException(status_code=401, detail="Missing token") try: payload = decode_token(token) except Exception: raise HTTPException(status_code=401, detail="Invalid token") user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="Invalid token") with SessionLocal() as session: user = session.get(User, int(user_id)) if not user: raise HTTPException(status_code=401, detail="Unknown user") return user def _mask_state_for_user(state: dict, username: str) -> dict: data = dict(state) game = data.get("game") if not game: return data masked_players = {} for name, info in game.get("players", {}).items(): pdata = dict(info) if name != username: resources = pdata.get("resources") or {} total = sum(resources.values()) if isinstance(resources, dict) else 0 pdata["resources"] = {"hidden": total} masked_players[name] = pdata game["players"] = masked_players data["game"] = game return data async def _broadcast(game_id: str, state: dict) -> None: connections = ws_connections.get(game_id, {}) dead = [] for ws, username in connections.items(): try: await ws.send_json({"type": "state", "data": _mask_state_for_user(state, username)}) except Exception: dead.append(ws) for ws in dead: connections.pop(ws, None) def _is_ai_only(state: dict) -> bool: slots = state.get("players", []) if not slots: return False return all(slot.get("is_ai") for slot in slots) async def _auto_advance(game_id: str) -> None: while True: response = await clients["game"].post(f"/games/{game_id}/advance") if response.status_code >= 400: break state = response.json() await _broadcast(game_id, state) if state.get("status") != "running": break await asyncio.sleep(0.2) def _ensure_task(game_id: str) -> None: if game_id in ai_tasks and not ai_tasks[game_id].done(): return ai_tasks[game_id] = asyncio.create_task(_auto_advance(game_id)) @app.get("/health") def health() -> dict: return {"status": "ok"} @app.post("/api/auth/register") def register(payload: dict): username = (payload.get("username") or "").strip() password = (payload.get("password") or "").strip() if not username or not password: raise HTTPException(status_code=400, detail="Username and password required") user = User(username=username, password_hash=hash_password(password)) with SessionLocal() as session: session.add(user) try: session.commit() except IntegrityError: session.rollback() raise HTTPException(status_code=400, detail="Username already exists") session.refresh(user) token = create_token(user.id, user.username) return {"token": token, "username": user.username} @app.post("/api/auth/login") def login(payload: dict): username = (payload.get("username") or "").strip() password = (payload.get("password") or "").strip() with SessionLocal() as session: user = session.exec(select(User).where(User.username == username)).first() if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid credentials") token = create_token(user.id, user.username) return {"token": token, "username": user.username} @app.get("/api/me") def me(user: User = Depends(_get_current_user)): return {"id": user.id, "username": user.username} @app.get("/api/lobby") async def lobby(user: User = Depends(_get_current_user)): response = await clients["game"].get("/games") response.raise_for_status() return response.json() @app.post("/api/games") async def create_game(payload: CreateGameRequest, user: User = Depends(_get_current_user)): data = payload.model_dump() data["created_by"] = user.username response = await clients["game"].post("/games", json=data) response.raise_for_status() return response.json() @app.post("/api/games/{game_id}/join") async def join_game(game_id: str, user: User = Depends(_get_current_user)): payload = JoinGameRequest(username=user.username, user_id=user.id) response = await clients["game"].post(f"/games/{game_id}/join", json=payload.model_dump()) response.raise_for_status() state = await clients["game"].get(f"/games/{game_id}") state.raise_for_status() await _broadcast(game_id, state.json()) return response.json() @app.post("/api/games/{game_id}/leave") async def leave_game(game_id: str, user: User = Depends(_get_current_user)): payload = JoinGameRequest(username=user.username, user_id=user.id) response = await clients["game"].post(f"/games/{game_id}/leave", json=payload.model_dump()) response.raise_for_status() state = await clients["game"].get(f"/games/{game_id}") state.raise_for_status() await _broadcast(game_id, state.json()) return response.json() @app.post("/api/games/{game_id}/add-ai") async def add_ai(game_id: str, payload: AddAIRequest, user: User = Depends(_get_current_user)): response = await clients["game"].post(f"/games/{game_id}/add_ai", json=payload.model_dump()) response.raise_for_status() state = await clients["game"].get(f"/games/{game_id}") state.raise_for_status() await _broadcast(game_id, state.json()) return response.json() @app.post("/api/games/{game_id}/start") async def start_game(game_id: str, user: User = Depends(_get_current_user)): response = await clients["game"].post(f"/games/{game_id}/start") response.raise_for_status() state = response.json() await _broadcast(game_id, state) if _is_ai_only(state): _ensure_task(game_id) return _mask_state_for_user(state, user.username) @app.get("/api/games/{game_id}") async def game_state(game_id: str, user: User = Depends(_get_current_user)): response = await clients["game"].get(f"/games/{game_id}") response.raise_for_status() return _mask_state_for_user(response.json(), user.username) @app.post("/api/games/{game_id}/action") async def take_action(game_id: str, payload: dict, user: User = Depends(_get_current_user)): action = payload.get("action") or {"type": payload.get("type"), "payload": payload.get("payload")} req = ActionRequest(actor=user.username, action=action) response = await clients["game"].post(f"/games/{game_id}/action", json=req.model_dump()) if response.status_code >= 400: raise HTTPException(status_code=response.status_code, detail=response.json().get("detail")) state = response.json() await _broadcast(game_id, state) if _is_ai_only(state): _ensure_task(game_id) return _mask_state_for_user(state, user.username) @app.post("/api/games/{game_id}/trade/offer") async def offer_trade(game_id: str, payload: TradeOfferRequest, user: User = Depends(_get_current_user)): if payload.from_player != user.username: raise HTTPException(status_code=403, detail="Not your player") response = await clients["game"].post(f"/games/{game_id}/trade/offer", json=payload.model_dump()) response.raise_for_status() state = await clients["game"].get(f"/games/{game_id}") state.raise_for_status() await _broadcast(game_id, state.json()) return response.json() @app.post("/api/games/{game_id}/trade/{trade_id}/respond") async def respond_trade(game_id: str, trade_id: str, payload: TradeRespondRequest, user: User = Depends(_get_current_user)): if payload.player != user.username: raise HTTPException(status_code=403, detail="Not your player") response = await clients["game"].post( f"/games/{game_id}/trade/{trade_id}/respond", json=payload.model_dump(), ) response.raise_for_status() state = await clients["game"].get(f"/games/{game_id}") state.raise_for_status() await _broadcast(game_id, state.json()) return response.json() @app.get("/api/models") async def list_models(user: User = Depends(_get_current_user)): response = await clients["ai"].get("/models") response.raise_for_status() return response.json() @app.get("/api/replays") async def list_replays(user: User = Depends(_get_current_user)): response = await clients["analytics"].get("/replays") response.raise_for_status() return response.json() @app.get("/api/replays/{replay_id}") async def replay_detail(replay_id: str, user: User = Depends(_get_current_user)): response = await clients["analytics"].get(f"/replays/{replay_id}") response.raise_for_status() return response.json() @app.get("/api/replays/{replay_id}/state") async def replay_state(replay_id: str, step: int = 0, user: User = Depends(_get_current_user)): response = await clients["analytics"].get(f"/replays/{replay_id}/state", params={"step": step}) response.raise_for_status() return response.json() @app.get("/api/replays/{replay_id}/export") async def replay_export(replay_id: str, user: User = Depends(_get_current_user)): response = await clients["analytics"].get(f"/replays/{replay_id}/export") response.raise_for_status() return response.json() @app.post("/api/replays/import") async def replay_import(payload: dict, user: User = Depends(_get_current_user)): response = await clients["analytics"].post("/replays/import", json=payload) if response.status_code >= 400: raise HTTPException(status_code=response.status_code, detail=response.json().get("detail")) return response.json() @app.get("/api/stats") async def stats(user: User = Depends(_get_current_user)): response = await clients["analytics"].get("/stats", params={"user": user.username}) response.raise_for_status() return response.json() @app.websocket("/ws/games/{game_id}") async def ws_game(websocket: WebSocket, game_id: str): await websocket.accept() try: user = _get_user_from_ws(websocket) except HTTPException: await websocket.close(code=1008) return ws_connections.setdefault(game_id, {})[websocket] = user.username try: response = await clients["game"].get(f"/games/{game_id}") if response.status_code == 200: await websocket.send_json({"type": "state", "data": _mask_state_for_user(response.json(), user.username)}) while True: await websocket.receive_text() except WebSocketDisconnect: ws_connections.get(game_id, {}).pop(websocket, None)