233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from typing import Any, Dict, Optional
|
||
|
||
import requests
|
||
from requests import Response, Session
|
||
from requests.adapters import HTTPAdapter
|
||
from urllib3.util.retry import Retry
|
||
from urllib.parse import urljoin
|
||
|
||
from app.services.appwrite_client import AppWriteClient
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
|
||
class CoCApi:
|
||
"""
|
||
Centralized API client for Code of Conquest.
|
||
All HTTP interactions go through _request() for consistent behavior and logging.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str = "http://localhost:8000",
|
||
default_timeout: tuple[float, float] = (5.0, 10.0),
|
||
max_retries: int = 3,
|
||
) -> None:
|
||
"""
|
||
:param base_url: Base URL for the API (no trailing slash needed).
|
||
:param default_timeout: (connect_timeout, read_timeout)
|
||
:param max_retries: Number of retries for transient network/server errors.
|
||
"""
|
||
self.base_url = base_url.rstrip("/")
|
||
self.default_timeout = default_timeout
|
||
self._aw = AppWriteClient()
|
||
|
||
# Base headers for JSON APIs.
|
||
self._base_headers: Dict[str, str] = {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
"User-Agent": "CoC-Client/1.0",
|
||
}
|
||
|
||
# Pre-configured Session with retries.
|
||
self._session = self._build_session(max_retries=max_retries)
|
||
|
||
# ---------- Public convenience methods ----------
|
||
|
||
def create_char(self, name:str, origin_story:str, race_id:str, profession_id:str) -> Dict[str, Any]:
|
||
payload = {
|
||
"name":name,
|
||
"origin_story":origin_story,
|
||
"race_id":race_id,
|
||
"profession_id":profession_id
|
||
}
|
||
result = self.post("/char/new",payload=payload)
|
||
player_uuid = result.get("result")
|
||
return player_uuid
|
||
|
||
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
return self._request("GET", path, params=params)
|
||
|
||
def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
return self._request("POST", path, json_body=payload)
|
||
|
||
def patch(self, path: str, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
return self._request("PATCH", path, json_body=json_body)
|
||
|
||
# ---------- Internal helpers ----------
|
||
|
||
def _build_session(self, max_retries: int) -> Session:
|
||
"""
|
||
Create a Session with sane retries for transient network/server failures.
|
||
We retry idempotent methods and some 5xx responses.
|
||
"""
|
||
session = requests.Session()
|
||
|
||
retries = Retry(
|
||
total=max_retries,
|
||
connect=max_retries,
|
||
read=max_retries,
|
||
backoff_factor=0.5,
|
||
status_forcelist=(502, 503, 504),
|
||
allowed_methods=frozenset(["GET", "HEAD", "OPTIONS", "TRACE"]),
|
||
raise_on_status=False,
|
||
)
|
||
|
||
adapter = HTTPAdapter(max_retries=retries)
|
||
session.mount("http://", adapter)
|
||
session.mount("https://", adapter)
|
||
return session
|
||
|
||
def _mint_jwt(self) -> str:
|
||
"""Mint a JWT from AppWrite; empty string if unavailable (don’t block requests)."""
|
||
try:
|
||
token = self._aw.mint_jwt() # expected to return dict-like
|
||
return token.get("jwt", "") if token else ""
|
||
except Exception as e:
|
||
logger.warning("Failed to mint JWT", extra={"error": str(e)})
|
||
return ""
|
||
|
||
def _auth_headers(self) -> Dict[str, str]:
|
||
"""
|
||
Build per-call headers with Authorization if a JWT is available.
|
||
Avoid mutating shared headers.
|
||
"""
|
||
headers = dict(self._base_headers)
|
||
jwt = self._mint_jwt()
|
||
if jwt:
|
||
headers["Authorization"] = f"Bearer {jwt}"
|
||
return headers
|
||
|
||
def _resolve_url(self, path_or_url: str) -> str:
|
||
"""
|
||
Accept either a full URL or a path (e.g., '/char/'). Join with base_url when needed.
|
||
"""
|
||
if path_or_url.lower().startswith(("http://", "https://")):
|
||
return path_or_url
|
||
return urljoin(self.base_url + "/", path_or_url.lstrip("/"))
|
||
|
||
def _safe_json(self, resp: Response) -> Dict[str, Any]:
|
||
"""
|
||
Attempt to parse JSON. If body is empty or not JSON, return {}.
|
||
"""
|
||
# 204 No Content or truly empty payloads
|
||
if resp.status_code == 204 or not resp.content:
|
||
return {}
|
||
|
||
try:
|
||
return resp.json()
|
||
except ValueError:
|
||
# Non-JSON payload; log preview and return empty.
|
||
preview = ""
|
||
try:
|
||
preview = resp.text[:400]
|
||
except Exception:
|
||
pass
|
||
logger.warning(
|
||
"Non-JSON response body",
|
||
extra={
|
||
"url": resp.request.url if resp.request else None,
|
||
"status": resp.status_code,
|
||
"body_preview": preview,
|
||
},
|
||
)
|
||
return {}
|
||
|
||
def _request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
params: Optional[Dict[str, Any]] = None,
|
||
json_body: Optional[Dict[str, Any]] = None,
|
||
timeout: Optional[tuple[float, float]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Central request executor. Never raises to the caller.
|
||
Returns parsed JSON on success or {} on any error.
|
||
"""
|
||
url = self._resolve_url(path)
|
||
headers = self._auth_headers()
|
||
to = timeout or self.default_timeout
|
||
|
||
try:
|
||
resp = self._session.request(
|
||
method=method.upper(),
|
||
url=url,
|
||
headers=headers,
|
||
params=params,
|
||
json=json_body,
|
||
timeout=to,
|
||
)
|
||
|
||
# Log and return {} on non-2xx
|
||
if not (200 <= resp.status_code < 300):
|
||
# Truncate body in logs to avoid huge entries
|
||
preview = ""
|
||
try:
|
||
preview = resp.text[:400]
|
||
except Exception:
|
||
pass
|
||
|
||
logger.warning(
|
||
"HTTP request failed",
|
||
extra={
|
||
"method": method.upper(),
|
||
"url": resp.request.url if resp.request else url,
|
||
"status": resp.status_code,
|
||
"params": params,
|
||
"json_body": json_body,
|
||
"body_preview": preview,
|
||
},
|
||
)
|
||
return {}
|
||
|
||
# Success path: parse JSON safely
|
||
return self._safe_json(resp)
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
# Network/timeout/connection errors
|
||
logger.warning(
|
||
"Network error during HTTP request",
|
||
extra={
|
||
"method": method.upper(),
|
||
"url": url,
|
||
"params": params,
|
||
"json_body": json_body,
|
||
"error_type": type(e).__name__,
|
||
"error": str(e),
|
||
},
|
||
exc_info=True,
|
||
)
|
||
return {}
|
||
except Exception as e:
|
||
# Absolute last-resort guardrail
|
||
logger.error(
|
||
"Unexpected error during HTTP request",
|
||
extra={
|
||
"method": method.upper(),
|
||
"url": url,
|
||
"params": params,
|
||
"json_body": json_body,
|
||
"error_type": type(e).__name__,
|
||
"error": str(e),
|
||
},
|
||
exc_info=True,
|
||
)
|
||
return {}
|