Files
FitTrack_GarminSync/cli/src/api/client.py
sstent fb6417b1a3 Implement CLI app for API interaction with MFA
- Create full CLI application with authentication, sync triggering, and status checking
- Implement MFA support for secure authentication
- Add token management with secure local storage
- Create API client for backend communication
- Implement data models for User Session, Sync Job, and Authentication Token
- Add command-line interface with auth and sync commands
- Include unit and integration tests
- Follow project constitution standards for Python 3.13, type hints, and code quality
- Support multiple output formats (table, JSON, CSV)
2025-12-18 15:23:56 -08:00

102 lines
3.8 KiB
Python

from typing import Any, Dict, Optional
import httpx
from ..models.token import AuthenticationToken
class ApiClient:
"""API client for communicating with backend"""
def __init__(self, base_url: str = "https://api.garmin.com"):
self.base_url = base_url
self.client = httpx.AsyncClient()
self.token: Optional[AuthenticationToken] = None
async def set_token(self, token: AuthenticationToken) -> None:
"""Set the authentication token for API requests"""
self.token = token
self.client.headers["Authorization"] = (
f"{token.token_type} {token.access_token}"
)
async def authenticate_user(
self, username: str, password: str, mfa_code: Optional[str] = None
) -> Dict[str, Any]:
"""Authenticate user via CLI with optional MFA"""
url = f"{self.base_url}/api/auth/cli/login"
payload = {"username": username, "password": password}
if mfa_code:
payload["mfa_code"] = mfa_code
try:
response = await self.client.post(url, json=payload)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
# Handle HTTP errors (4xx, 5xx)
error_detail = await self._extract_error_detail(response)
raise Exception(f"API Error: {e.response.status_code} - {error_detail}")
except httpx.RequestError as e:
# Handle request errors (network, timeout, etc.)
raise Exception(f"Request Error: {str(e)}")
async def trigger_sync(
self,
sync_type: str,
date_range: Optional[Dict[str, str]] = None,
force_full_sync: bool = False,
) -> Dict[str, Any]:
"""Trigger a sync operation"""
url = f"{self.base_url}/api/sync/cli/trigger"
payload = {"sync_type": sync_type, "force_full_sync": force_full_sync}
if date_range:
payload["date_range"] = date_range
try:
response = await self.client.post(url, json=payload)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
# Handle HTTP errors (4xx, 5xx) including 409 conflict
error_detail = await self._extract_error_detail(response)
raise Exception(f"API Error: {e.response.status_code} - {error_detail}")
except httpx.RequestError as e:
# Handle request errors (network, timeout, etc.)
raise Exception(f"Request Error: {str(e)}")
async def get_sync_status(self, job_id: Optional[str] = None) -> Dict[str, Any]:
"""Get sync status for all jobs or a specific job"""
if job_id:
url = f"{self.base_url}/api/sync/cli/status/{job_id}"
else:
url = f"{self.base_url}/api/sync/cli/status"
try:
response = await self.client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
# Handle HTTP errors (4xx, 5xx)
error_detail = await self._extract_error_detail(response)
raise Exception(f"API Error: {e.response.status_code} - {error_detail}")
except httpx.RequestError as e:
# Handle request errors (network, timeout, etc.)
raise Exception(f"Request Error: {str(e)}")
async def _extract_error_detail(self, response: httpx.Response) -> str:
"""Extract error details from response"""
try:
error_json = response.json()
return error_json.get("error", "Unknown error")
except Exception:
return response.text[:200] # Return first 200 chars if not JSON
async def close(self) -> None:
"""Close the HTTP client"""
await self.client.aclose()