226 lines
8.1 KiB
Python
226 lines
8.1 KiB
Python
"""
|
|
WebSocket Route Handler (Section 2, 3, 4, 15, 16, 22, 23, 24, 25).
|
|
Endpoint: WS /api/v1/ws/onboarding/{session_id}
|
|
Handles connection lifecycle, authentication, session validation, input sanitization, rate limiting, and message dispatch.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Optional
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database.database import get_db
|
|
from app.core.security import get_auth_context, verify_session_ownership, AuthContext
|
|
from app.services.onboarding_service import OnboardingService
|
|
from app.services.websocket_service import WebSocketService
|
|
from app.api.websocket.manager import manager
|
|
from app.api.websocket.schemas import (
|
|
WSEventType,
|
|
WSErrorCode,
|
|
WSConnectionAck,
|
|
WSPatientMessage,
|
|
WSErrorEvent,
|
|
)
|
|
|
|
from app.database.database import SessionLocal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/v1/ws/onboarding",
|
|
tags=["WebSocket Onboarding"],
|
|
)
|
|
|
|
# In-memory per-connection rate limiter (Section 26)
|
|
# Maps session_id -> list of message timestamps
|
|
WS_RATE_LIMIT_STORE = {}
|
|
|
|
|
|
def check_ws_rate_limit(session_id: str, limit: int = 30, window_sec: int = 60) -> bool:
|
|
import time
|
|
now = time.time()
|
|
timestamps = WS_RATE_LIMIT_STORE.get(session_id, [])
|
|
timestamps = [t for t in timestamps if now - t < window_sec]
|
|
if len(timestamps) >= limit:
|
|
return False
|
|
timestamps.append(now)
|
|
WS_RATE_LIMIT_STORE[session_id] = timestamps
|
|
return True
|
|
|
|
|
|
@router.websocket("/{session_id}")
|
|
async def websocket_onboarding_endpoint(
|
|
websocket: WebSocket,
|
|
session_id: str,
|
|
token: Optional[str] = Query(None),
|
|
x_api_key: Optional[str] = Query(None, alias="api_key"),
|
|
x_patient_id: Optional[str] = Query(None, alias="patient_id"),
|
|
):
|
|
"""
|
|
Real-Time Patient Onboarding WebSocket Endpoint.
|
|
WS /api/v1/ws/onboarding/{session_id}
|
|
"""
|
|
db = SessionLocal()
|
|
|
|
|
|
try:
|
|
await _handle_websocket_lifecycle(
|
|
websocket=websocket,
|
|
session_id=session_id,
|
|
db=db,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
async def _handle_websocket_lifecycle(
|
|
websocket: WebSocket,
|
|
session_id: str,
|
|
db: Session,
|
|
):
|
|
onboarding_service = OnboardingService()
|
|
ws_service = WebSocketService()
|
|
|
|
await websocket.accept()
|
|
|
|
# Extract query params directly from WebSocket request URL (Section 25)
|
|
query_token = websocket.query_params.get("token")
|
|
query_api_key = websocket.query_params.get("api_key")
|
|
query_patient_id = websocket.query_params.get("patient_id")
|
|
|
|
auth_token = query_token or query_api_key or websocket.headers.get("x-api-key")
|
|
if not auth_token:
|
|
# Allow test connection token if in header or subprotocol
|
|
sec_protocol = websocket.headers.get("sec-websocket-protocol")
|
|
if sec_protocol:
|
|
auth_token = sec_protocol
|
|
|
|
try:
|
|
auth_context = get_auth_context(
|
|
api_key=auth_token,
|
|
credentials=None,
|
|
x_patient_id=query_patient_id or websocket.headers.get("x-patient-id")
|
|
)
|
|
except Exception as e:
|
|
logger.warning("websocket_auth_failed", extra={"session_id": session_id, "error": str(e)})
|
|
await websocket.send_json(
|
|
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Authentication failed.").model_dump()
|
|
)
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
# 2. Validate session exists & active (Section 24)
|
|
session = onboarding_service.get_session(db, session_id)
|
|
if not session:
|
|
logger.warning("websocket_session_not_found", extra={"session_id": session_id})
|
|
await websocket.send_json(
|
|
WSErrorEvent(code=WSErrorCode.SESSION_NOT_FOUND.value, message="Session not found.").model_dump()
|
|
)
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
# 3. Verify session ownership (Section 25)
|
|
try:
|
|
verify_session_ownership(session.patient_id, auth_context)
|
|
except Exception as e:
|
|
logger.warning("websocket_unauthorized_session_access", extra={"session_id": session_id, "error": str(e)})
|
|
await websocket.send_json(
|
|
WSErrorEvent(code=WSErrorCode.UNAUTHORIZED.value, message="Unauthorized session access.").model_dump()
|
|
)
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
if session.status != "in_progress":
|
|
logger.warning("websocket_session_expired", extra={"session_id": session_id})
|
|
await websocket.send_json(
|
|
WSErrorEvent(code=WSErrorCode.SESSION_EXPIRED.value, message="Session expired or completed.").model_dump()
|
|
)
|
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
return
|
|
|
|
# 4. Register connection & send ACK (Section 3 & 4)
|
|
await manager.connect(session_id, websocket)
|
|
|
|
ack = WSConnectionAck(session_id=session_id, status="connected").model_dump()
|
|
await manager.send_json(websocket, ack)
|
|
|
|
try:
|
|
while True:
|
|
# 5. Receive text message
|
|
data_text = await websocket.receive_text()
|
|
|
|
# 6. Check Rate Limiting (Section 26)
|
|
if not check_ws_rate_limit(session_id, limit=30, window_sec=60):
|
|
await manager.send_json(
|
|
websocket,
|
|
WSErrorEvent(
|
|
code=WSErrorCode.RATE_LIMITED.value,
|
|
message="Please wait a moment before sending another message."
|
|
).model_dump()
|
|
)
|
|
continue
|
|
|
|
# 7. Validate JSON structure & size (Section 15 & 16)
|
|
if len(data_text) > 5000:
|
|
await manager.send_json(
|
|
websocket,
|
|
WSErrorEvent(
|
|
code=WSErrorCode.MESSAGE_TOO_LARGE.value,
|
|
message="Message exceeds maximum allowed length of 5000 characters."
|
|
).model_dump()
|
|
)
|
|
continue
|
|
|
|
try:
|
|
payload = json.loads(data_text)
|
|
except Exception:
|
|
await manager.send_json(
|
|
websocket,
|
|
WSErrorEvent(
|
|
code=WSErrorCode.INVALID_MESSAGE.value,
|
|
message="Please send a valid JSON message."
|
|
).model_dump()
|
|
)
|
|
continue
|
|
|
|
if not isinstance(payload, dict) or payload.get("type") != WSEventType.PATIENT_MESSAGE.value:
|
|
await manager.send_json(
|
|
websocket,
|
|
WSErrorEvent(
|
|
code=WSErrorCode.INVALID_MESSAGE.value,
|
|
message="Unsupported message type. Expected 'patient.message'."
|
|
).model_dump()
|
|
)
|
|
continue
|
|
|
|
message_id = str(payload.get("message_id") or "").strip()
|
|
text = str(payload.get("text") or "").strip()
|
|
|
|
if not message_id or not text:
|
|
await manager.send_json(
|
|
websocket,
|
|
WSErrorEvent(
|
|
code=WSErrorCode.INVALID_MESSAGE.value,
|
|
message="Message must include non-empty 'message_id' and 'text'."
|
|
).model_dump()
|
|
)
|
|
continue
|
|
|
|
# 8. Session-level lock for sequential processing per session (Section 18 & 19)
|
|
async with manager.get_session_lock(session_id):
|
|
await ws_service.handle_patient_message(
|
|
websocket=websocket,
|
|
db=db,
|
|
session_id=session_id,
|
|
message_id=message_id,
|
|
text=text,
|
|
)
|
|
|
|
except WebSocketDisconnect:
|
|
manager.disconnect(session_id, websocket)
|
|
logger.info("websocket_client_disconnected", extra={"session_id": session_id})
|
|
except Exception as e:
|
|
manager.disconnect(session_id, websocket)
|
|
logger.error("websocket_unexpected_error", extra={"session_id": session_id, "error": str(e)})
|