""" API Client for Public Web Frontend Provides HTTP request wrapper for communicating with the API backend. Handles session cookie forwarding, error handling, and response parsing. """ import requests from flask import request as flask_request, session as flask_session from typing import Optional, Any from app.config import load_config from .logging import get_logger logger = get_logger(__name__) class APIError(Exception): """Base exception for API errors.""" def __init__(self, message: str, status_code: int = 500, details: Optional[dict] = None): self.message = message self.status_code = status_code self.details = details or {} super().__init__(self.message) class APITimeoutError(APIError): """Raised when API request times out.""" def __init__(self, message: str = "Request timed out"): super().__init__(message, status_code=504) class APINotFoundError(APIError): """Raised when resource not found (404).""" def __init__(self, message: str = "Resource not found"): super().__init__(message, status_code=404) class APIAuthenticationError(APIError): """Raised when authentication fails (401).""" def __init__(self, message: str = "Authentication required"): super().__init__(message, status_code=401) class APIClient: """ HTTP client for making requests to the API backend. Usage: client = APIClient() # GET request response = client.get("/api/v1/characters") characters = response.get("result", []) # POST request response = client.post("/api/v1/characters", data={"name": "Hero"}) # DELETE request client.delete("/api/v1/characters/123") """ def __init__(self): """Initialize API client with config.""" self.config = load_config() self.base_url = self.config.api.base_url.rstrip('/') self.timeout = self.config.api.timeout self.verify_ssl = self.config.api.verify_ssl def _get_cookies(self) -> dict: """ Get cookies to forward to API. Returns: Dictionary of cookies to forward (session cookie). """ cookies = {} # Get session cookie from Flask session (stored after login) try: if 'api_session_cookie' in flask_session: cookies['coc_session'] = flask_session['api_session_cookie'] except RuntimeError: # Outside of request context pass return cookies def _save_session_cookie(self, response: requests.Response) -> None: """ Save session cookie from API response to Flask session. Args: response: Response from requests library. """ try: session_cookie = response.cookies.get('coc_session') if session_cookie: flask_session['api_session_cookie'] = session_cookie logger.debug("Saved API session cookie to Flask session") except RuntimeError: # Outside of request context pass def _get_headers(self) -> dict: """ Get default headers for API requests. Returns: Dictionary of headers. """ return { 'Content-Type': 'application/json', 'Accept': 'application/json' } def _handle_response(self, response: requests.Response) -> dict: """ Handle API response and raise appropriate exceptions. Args: response: Response from requests library. Returns: Parsed JSON response. Raises: APIError: For various HTTP error codes. """ try: data = response.json() except ValueError: data = {} # Check for errors if response.status_code == 401: error_msg = data.get('error', {}).get('message', 'Authentication required') raise APIAuthenticationError(error_msg) if response.status_code == 404: error_msg = data.get('error', {}).get('message', 'Resource not found') raise APINotFoundError(error_msg) if response.status_code >= 400: error_msg = data.get('error', {}).get('message', f'API error: {response.status_code}') error_details = data.get('error', {}).get('details', {}) raise APIError(error_msg, response.status_code, error_details) return data def get(self, endpoint: str, params: Optional[dict] = None) -> dict: """ Make GET request to API. Args: endpoint: API endpoint (e.g., "/api/v1/characters"). params: Optional query parameters. Returns: Parsed JSON response. Raises: APIError: For various error conditions. """ url = f"{self.base_url}{endpoint}" try: response = requests.get( url, params=params, headers=self._get_headers(), cookies=self._get_cookies(), timeout=self.timeout, verify=self.verify_ssl ) logger.debug("API GET request", url=url, status=response.status_code) return self._handle_response(response) except requests.exceptions.Timeout: logger.error("API timeout", url=url) raise APITimeoutError() except requests.exceptions.ConnectionError as e: logger.error("API connection error", url=url, error=str(e)) raise APIError(f"Could not connect to API: {str(e)}", status_code=503) except requests.exceptions.RequestException as e: logger.error("API request error", url=url, error=str(e)) raise APIError(f"Request failed: {str(e)}") def post(self, endpoint: str, data: Optional[dict] = None) -> dict: """ Make POST request to API. Args: endpoint: API endpoint (e.g., "/api/v1/characters"). data: Request body data. Returns: Parsed JSON response. Raises: APIError: For various error conditions. """ url = f"{self.base_url}{endpoint}" try: response = requests.post( url, json=data or {}, headers=self._get_headers(), cookies=self._get_cookies(), timeout=self.timeout, verify=self.verify_ssl ) logger.debug("API POST request", url=url, status=response.status_code) # Save session cookie if present (for login responses) self._save_session_cookie(response) return self._handle_response(response) except requests.exceptions.Timeout: logger.error("API timeout", url=url) raise APITimeoutError() except requests.exceptions.ConnectionError as e: logger.error("API connection error", url=url, error=str(e)) raise APIError(f"Could not connect to API: {str(e)}", status_code=503) except requests.exceptions.RequestException as e: logger.error("API request error", url=url, error=str(e)) raise APIError(f"Request failed: {str(e)}") def delete(self, endpoint: str) -> dict: """ Make DELETE request to API. Args: endpoint: API endpoint (e.g., "/api/v1/characters/123"). Returns: Parsed JSON response. Raises: APIError: For various error conditions. """ url = f"{self.base_url}{endpoint}" try: response = requests.delete( url, headers=self._get_headers(), cookies=self._get_cookies(), timeout=self.timeout, verify=self.verify_ssl ) logger.debug("API DELETE request", url=url, status=response.status_code) return self._handle_response(response) except requests.exceptions.Timeout: logger.error("API timeout", url=url) raise APITimeoutError() except requests.exceptions.ConnectionError as e: logger.error("API connection error", url=url, error=str(e)) raise APIError(f"Could not connect to API: {str(e)}", status_code=503) except requests.exceptions.RequestException as e: logger.error("API request error", url=url, error=str(e)) raise APIError(f"Request failed: {str(e)}") def put(self, endpoint: str, data: Optional[dict] = None) -> dict: """ Make PUT request to API. Args: endpoint: API endpoint. data: Request body data. Returns: Parsed JSON response. Raises: APIError: For various error conditions. """ url = f"{self.base_url}{endpoint}" try: response = requests.put( url, json=data or {}, headers=self._get_headers(), cookies=self._get_cookies(), timeout=self.timeout, verify=self.verify_ssl ) logger.debug("API PUT request", url=url, status=response.status_code) return self._handle_response(response) except requests.exceptions.Timeout: logger.error("API timeout", url=url) raise APITimeoutError() except requests.exceptions.ConnectionError as e: logger.error("API connection error", url=url, error=str(e)) raise APIError(f"Could not connect to API: {str(e)}", status_code=503) except requests.exceptions.RequestException as e: logger.error("API request error", url=url, error=str(e)) raise APIError(f"Request failed: {str(e)}") # Singleton instance _api_client: Optional[APIClient] = None def get_api_client() -> APIClient: """ Get singleton API client instance. Returns: APIClient instance. """ global _api_client if _api_client is None: _api_client = APIClient() return _api_client