import time from typing import Any import httpx from fastapi import HTTPException, status from .config import Settings class MicrosoftGraphClient: def __init__(self, settings: Settings) -> None: self._settings = settings self._access_token: str | None = None self._expires_at = 0.0 async def request( self, method: str, path: str, *, params: dict[str, Any] | None = None, json: Any | None = None, content: bytes | None = None, headers: dict[str, str] | None = None, ) -> Any: token = await self._get_access_token() request_headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", } if headers: request_headers.update(headers) url = self._build_url(path) async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client: response = await client.request( method, url, params=params, json=json, content=content, headers=request_headers, ) if response.status_code >= 400: raise self._graph_exception(response) if response.status_code == status.HTTP_204_NO_CONTENT or not response.content: return None return response.json() async def _get_access_token(self) -> str: if self._access_token and time.time() < self._expires_at - 60: return self._access_token if not self._settings.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Microsoft 365 credentials are not configured.", ) data = { "client_id": self._settings.client_id, "client_secret": self._settings.client_secret, "scope": self._settings.graph_scope, "grant_type": "client_credentials", } async with httpx.AsyncClient(timeout=self._settings.request_timeout_seconds) as client: response = await client.post(self._settings.token_url, data=data) if response.status_code >= 400: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail={ "message": "Unable to authenticate with Microsoft identity platform.", "upstream_status": response.status_code, "upstream_response": self._safe_json(response), }, ) payload = response.json() self._access_token = payload["access_token"] self._expires_at = time.time() + int(payload.get("expires_in", 3599)) return self._access_token def _build_url(self, path: str) -> str: if path.startswith("https://"): return path return f"{self._settings.graph_base_url.rstrip('/')}/{path.lstrip('/')}" def _graph_exception(self, response: httpx.Response) -> HTTPException: return HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail={ "message": "Microsoft Graph request failed.", "upstream_status": response.status_code, "upstream_response": self._safe_json(response), }, ) @staticmethod def _safe_json(response: httpx.Response) -> Any: try: return response.json() except ValueError: return response.text