This commit is contained in:
JiriUhlir
2026-05-27 10:23:25 +02:00
parent d85e60095f
commit 896657dd6f
8 changed files with 558 additions and 11 deletions
+105
View File
@@ -0,0 +1,105 @@
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