Initial commit: Trading Portal - FastAPI + React + PostgreSQL
This commit is contained in:
Executable
Executable
+188
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
WebSocket endpoint for real-time candle and ticker updates.
|
||||
|
||||
Authentication via JWT token passed as a query parameter (``?token=...``).
|
||||
Once authenticated, clients can subscribe/unsubscribe to symbol+timeframe+exchange
|
||||
channels and receive live candle updates pushed by the background scheduler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.ws_manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/v1/candles")
|
||||
async def candle_websocket(websocket: WebSocket) -> None:
|
||||
"""
|
||||
WebSocket endpoint for real-time candle updates.
|
||||
|
||||
**Authentication:** via query parameter ``?token=JWT``
|
||||
|
||||
**Client → Server messages:**
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{"action": "subscribe", "symbol": "BTC/USDT", "timeframe": "1h", "exchange": "mexc"}
|
||||
{"action": "unsubscribe", "symbol": "BTC/USDT", "timeframe": "1h", "exchange": "mexc"}
|
||||
|
||||
**Server → Client messages:**
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{"type": "connection", "status": "connected"}
|
||||
{"type": "connection", "status": "authenticated"}
|
||||
{"type": "candle", "data": {"symbol": "...", "timeframe": "...", ...}}
|
||||
{"type": "ticker", "data": {"symbol": "...", "price": 123.45, ...}}
|
||||
{"type": "error", "message": "..."}
|
||||
"""
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Accept the connection
|
||||
# ------------------------------------------------------------------
|
||||
await websocket.accept()
|
||||
logger.info("WebSocket connection accepted: %s", id(websocket))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Send connection status
|
||||
# ------------------------------------------------------------------
|
||||
await _send_json(websocket, {"type": "connection", "status": "connected"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Extract JWT token: try subprotocol header first (secure),
|
||||
# then fall back to query parameter (legacy compat)
|
||||
# ------------------------------------------------------------------
|
||||
token: str | None = None
|
||||
|
||||
# Try Sec-WebSocket-Protocol header (subprotocol-based auth)
|
||||
subprotocols = websocket.headers.get("sec-websocket-protocol", "")
|
||||
for sp in subprotocols.split(","):
|
||||
sp = sp.strip()
|
||||
if sp.startswith("token,"):
|
||||
token = sp.split(",", 1)[1].strip()
|
||||
break
|
||||
elif sp.startswith("token-"):
|
||||
token = sp[6:].strip()
|
||||
break
|
||||
|
||||
# Fallback: query parameter (backward compat, less secure)
|
||||
if not token:
|
||||
token = websocket.query_params.get("token")
|
||||
|
||||
if not token:
|
||||
await _send_json(websocket, {"type": "error", "message": "Missing token query parameter"})
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Verify JWT token
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
decode_token(token)
|
||||
except Exception as exc:
|
||||
error_msg = str(exc) if str(exc) else "Invalid or expired token"
|
||||
await _send_json(websocket, {"type": "error", "message": error_msg})
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Send authenticated status
|
||||
# ------------------------------------------------------------------
|
||||
await _send_json(websocket, {"type": "connection", "status": "authenticated"})
|
||||
logger.info("WebSocket %s authenticated", id(websocket))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Register this websocket with the connection manager
|
||||
# (it starts with no subscriptions — the client will subscribe below)
|
||||
# ------------------------------------------------------------------
|
||||
# manager.subscribe(...) is called per-subscription in the message loop
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Message loop — handle subscribe / unsubscribe
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await _send_json(websocket, {"type": "error", "message": "Invalid JSON"})
|
||||
continue
|
||||
|
||||
action: str | None = data.get("action")
|
||||
symbol: str | None = data.get("symbol")
|
||||
timeframe: str | None = data.get("timeframe")
|
||||
exchange: str | None = data.get("exchange")
|
||||
|
||||
if not action:
|
||||
await _send_json(websocket, {"type": "error", "message": "Missing 'action' field"})
|
||||
continue
|
||||
|
||||
if action not in ("subscribe", "unsubscribe"):
|
||||
await _send_json(
|
||||
websocket,
|
||||
{"type": "error", "message": f"Unknown action: {action}"},
|
||||
)
|
||||
continue
|
||||
|
||||
if not all([symbol, timeframe, exchange]):
|
||||
await _send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Missing one or more required fields: symbol, timeframe, exchange",
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if action == "subscribe":
|
||||
await manager.subscribe(websocket, symbol, timeframe, exchange)
|
||||
logger.info(
|
||||
"WebSocket %s subscribed to %s/%s/%s",
|
||||
id(websocket),
|
||||
exchange,
|
||||
symbol,
|
||||
timeframe,
|
||||
)
|
||||
elif action == "unsubscribe":
|
||||
await manager.unsubscribe(websocket, symbol, timeframe, exchange)
|
||||
logger.info(
|
||||
"WebSocket %s unsubscribed from %s/%s/%s",
|
||||
id(websocket),
|
||||
exchange,
|
||||
symbol,
|
||||
timeframe,
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket %s disconnected", id(websocket))
|
||||
except Exception:
|
||||
logger.exception("Unexpected error in WebSocket handler %s", id(websocket))
|
||||
finally:
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Clean up — remove from all subscriptions
|
||||
# ------------------------------------------------------------------
|
||||
await manager.unsubscribe_all(websocket)
|
||||
logger.info("WebSocket %s cleaned up (unsubscribed from all channels)", id(websocket))
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Internal helpers
|
||||
# ====================================================================
|
||||
|
||||
|
||||
async def _send_json(websocket: WebSocket, data: dict) -> None:
|
||||
"""Send a JSON-serialisable dict to the websocket, ignoring errors."""
|
||||
try:
|
||||
await websocket.send_json(data)
|
||||
except Exception:
|
||||
logger.debug("Failed to send JSON to WebSocket %s (may be disconnected)", id(websocket))
|
||||
Reference in New Issue
Block a user