Coverage for src/henrri_connect/connect/asynchro.py: 80%
110 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 10:54 +0200
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 10:54 +0200
1"""
2Client HTTP Henrri : implémentations asynchrone.
4Constants:
5- ``henrri_connect.connect.asynchro._BASE_URL``
6 URL de base de l'API Henrri.
7- ``henrri_connect.connect.asynchro.APP_VERSION``
8 Version de l'application.
9- ``henrri_connect.connect.asynchro.APP_X_VERSION``
10 Version de l'application.
12Classes:
13- ``henrri_connect.connect.asynchro.AsyncHenrriClient``
14 Client HTTP Henrri asynchrone.
16Exceptions:
17- ``henrri_connect.exc.HenrriAuthError``
18 Erreur d'authentification (HTTP 401).
20Exemples:
21.. code-block:: python
23 # Client asynchrone
24 from henrri_connect import AsyncHenrriClient
26 client = AsyncHenrriClient("client_id", "client_secret", base_url="<production_url>")
27"""
29from __future__ import annotations
31import logging
32from typing import Any, TYPE_CHECKING
33import httpx
34from ..exc import HenrriAuthError
35from ..models import TokenResponse
36from ..utils import raise_for_status
37if TYPE_CHECKING:
38 from ..companies import AsyncCompaniesClient
39 from ..customers import AsyncCustomersClient
40 from ..document_line_types import AsyncDocumentLineTypesClient
41 from ..document_lines import AsyncDocumentLinesClient
42 from ..document_types import AsyncDocumentTypesClient
43 from ..documents import AsyncDocumentsClient
44 from ..item_categories import AsyncItemCategoriesClient
45 from ..items import AsyncItemsClient
46 from ..revenues import AsyncRevenuesClient
47 from ..secures import AsyncSecuresClient
48 from ..units import AsyncUnitsClient
49 from ..users import AsyncUsersClient
51logger = logging.getLogger(__name__)
53_BASE_URL = "https://api-sandbox.henrri.io"
54APP_VERSION = "application/json"
55APP_X_VERSION = "1.0"
57class AsyncHenrriClient:
58 """
59 Client HTTP asynchrone pour l'API Henrri.
60 Gère l'authentification, le refresh-token et les erreurs HTTP.
61 Fournit des sous-clients pour chaque groupe d'endpoints (users, companies, etc.)
62 Authentification automatique avec gestion du refresh token
63 Gestion centralisée des erreurs HTTP avec exceptions personnalisées
65 Arguments
66 - client_id: Identifiant client pour l'authentification.
67 - client_secret: Secret client pour l'authentification.
68 - base_url: URL de base de l'API (défaut : sandbox Henrri).
70 Methodes
71 - authenticate: Authentifie le client.
72 - request: Effectue une requête HTTP authentifiée.
73 - close: Ferme le client HTTP sous-jacent.
74 """
76 users: "AsyncUsersClient"
77 companies: "AsyncCompaniesClient"
78 customers: "AsyncCustomersClient"
79 documents: "AsyncDocumentsClient"
80 document_lines: "AsyncDocumentLinesClient"
81 document_line_types: "AsyncDocumentLineTypesClient"
82 document_types: "AsyncDocumentTypesClient"
83 items: "AsyncItemsClient"
84 item_categories: "AsyncItemCategoriesClient"
85 units: "AsyncUnitsClient"
86 revenues: "AsyncRevenuesClient"
87 secures: "AsyncSecuresClient"
89 def __init__(
90 self,
91 client_id: str,
92 client_secret: str,
93 *,
94 base_url: str = _BASE_URL,
95 ) -> None:
96 self._client_id = client_id
97 self._client_secret = client_secret
98 self._base_url = base_url.rstrip("/")
99 self._access_token: str | None = None
100 self._refresh_token_str: str | None = None
101 self._http = httpx.AsyncClient(timeout=30.0)
102 self._init_subclients()
104 def _init_subclients(self) -> None:
105 from ..companies import AsyncCompaniesClient # pylint: disable=C0415
106 from ..customers import AsyncCustomersClient # pylint: disable=C0415
107 from ..document_line_types import AsyncDocumentLineTypesClient # pylint: disable=C0415
108 from ..document_lines import AsyncDocumentLinesClient # pylint: disable=C0415
109 from ..document_types import AsyncDocumentTypesClient # pylint: disable=C0415
110 from ..documents import AsyncDocumentsClient # pylint: disable=C0415
111 from ..item_categories import AsyncItemCategoriesClient # pylint: disable=C0415
112 from ..items import AsyncItemsClient # pylint: disable=C0415
113 from ..revenues import AsyncRevenuesClient # pylint: disable=C0415
114 from ..secures import AsyncSecuresClient # pylint: disable=C0415
115 from ..units import AsyncUnitsClient # pylint: disable=C0415
116 from ..users import AsyncUsersClient # pylint: disable=C0415
118 self.users = AsyncUsersClient(self)
119 self.companies = AsyncCompaniesClient(self)
120 self.customers = AsyncCustomersClient(self)
121 self.documents = AsyncDocumentsClient(self)
122 self.document_lines = AsyncDocumentLinesClient(self)
123 self.document_line_types = AsyncDocumentLineTypesClient(self)
124 self.document_types = AsyncDocumentTypesClient(self)
125 self.items = AsyncItemsClient(self)
126 self.item_categories = AsyncItemCategoriesClient(self)
127 self.units = AsyncUnitsClient(self)
128 self.revenues = AsyncRevenuesClient(self)
129 self.secures = AsyncSecuresClient(self)
131 def _url(self, path: str) -> str:
132 return f"{self._base_url}{path}"
134 def _headers(self, *, authenticated: bool = True) -> dict[str, str]:
135 headers: dict[str, str] = {
136 "Content-Type": APP_VERSION,
137 "Accept": APP_VERSION,
138 "X-Version": APP_X_VERSION,
139 }
140 logger.debug("Construction des headers (authentifié=%s)", authenticated)
141 if authenticated:
142 if not self._access_token:
143 logger.error("Tentative de requête authentifiée sans token d'accès.")
144 raise HenrriAuthError(
145 401,
146 "Non authentifié. Appelez await authenticate() avant toute requête."
147 )
148 headers["Authorization"] = f"Bearer {self._access_token}"
149 logger.debug("Headers avec token d'accès ajouté.")
150 return headers
152 async def authenticate(self) -> TokenResponse:
153 """
154 Authentifie le client et stocke le token d'accès.
156 Arguments
157 - None
159 Returns
160 - TokenResponse : objet contenant access_token et refresh_token.
161 """
162 resp: httpx.Response = await self._http.post(
163 self._url("/v1/users/authenticate"),
164 headers=self._headers(authenticated=False),
165 json={"clientId": self._client_id, "clientSecret": self._client_secret},
166 )
167 raise_for_status(resp)
168 token: TokenResponse = TokenResponse.model_validate(resp.json())
169 self._access_token = token.access_token
170 self._refresh_token_str = token.refresh_token
171 logger.debug("Authentification réussie, token d'accès obtenu.")
172 logger.info(
173 "Authentification réussie. Access token valide pour %d secondes.",
174 token.expires_in
175 )
176 return token
178 async def _do_refresh(self) -> None:
179 """
180 Rafraîchit le token via le refresh token, ou ré-authentifie en cas d'échec.
182 Arguments
183 - None
185 Returns
186 - None
187 """
188 resp: httpx.Response = await self._http.post(
189 self._url("/v1/users/refresh-token"),
190 headers=self._headers(authenticated=False),
191 json={"refreshToken": self._refresh_token_str},
192 )
193 if resp.is_success:
194 logger.debug("Rafraîchissement du token via le refresh token.")
195 token: TokenResponse = TokenResponse.model_validate(resp.json())
196 self._access_token = token.access_token
197 if token.refresh_token:
198 logger.debug("Nouveau refresh token reçu, mise à jour du refresh token stocké.")
199 self._refresh_token_str = token.refresh_token
200 else:
201 await self.authenticate()
203 async def _request(
204 self,
205 method: str,
206 path: str,
207 *,
208 authenticated: bool = True,
209 **kwargs: Any,
210 ) -> httpx.Response:
211 """
212 Effectue une requête HTTP avec gestion automatique de l'authentification.
214 Arguments
215 - method (str) : Le type de la requête (GET, POST, etc.).
216 - path (str) : L'URL de la requête.
217 - authenticated (bool, optional) : Indique si la requête doit быть authentifiée.
218 - kwargs (dict, optional) : Dictionnaire de paramètres supplémentaires pour la requête.
220 Returns
221 - httpx.Response : La réponse de la requête.
223 Raises
224 - HenrriAuthError : Erreur d'authentification.
225 """
226 if authenticated and not self._access_token:
227 await self.authenticate()
229 logger.debug(
230 "Envoi de la requête %s %s (authentifié=%s)",
231 method,
232 path,
233 authenticated,
234 )
235 resp: httpx.Response = await self._http.request(
236 method,
237 self._url(path),
238 headers=self._headers(authenticated=authenticated),
239 **kwargs,
240 )
242 if resp.status_code == 401 and authenticated:
243 logger.debug("Réponse 401 reçue, tentative de rafraîchissement du token.")
244 if self._refresh_token_str:
245 await self._do_refresh()
246 else:
247 await self.authenticate()
248 resp: httpx.Response = await self._http.request(
249 method,
250 self._url(path),
251 headers=self._headers(authenticated=True),
252 **kwargs,
253 )
255 raise_for_status(resp)
256 return resp
258 async def request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
259 """
260 Effectue une requête HTTP authentifiée (asynchrone).
262 Arguments
263 - method: Méthode HTTP (GET, POST, etc.).
264 - endpoint: Chemin relatif (ex: '/v1/companies/42').
265 - kwargs: Arguments supplémentaires pour httpx.request().
267 Returns
268 - httpx.Response: La réponse de la requête HTTP.
269 """
270 return await self._request(method, endpoint, **kwargs)
272 async def close(self) -> None:
273 """
274 Ferme le client HTTP sous-jacent.
276 Arguments
277 - None
279 Returns
280 - None
281 """
282 logger.info("Fermeture du client HTTP.")
283 await self._http.aclose()
285 async def __aenter__(self) -> AsyncHenrriClient:
286 return self
288 async def __aexit__(self, *args: Any) -> None:
289 await self.close()