mirror of
https://github.com/sstent/FitTrack_GarminSync.git
synced 2026-01-25 08:35:23 +00:00
- 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)
102 lines
3.8 KiB
Python
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()
|