Coverage for packages/core/src/integrify/api.py: 88%
142 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-19 02:39 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-19 02:39 +0000
1import string
2from collections.abc import Callable, Coroutine
3from functools import cached_property
4from typing import Any, ClassVar, Optional, TypeVar
5from urllib.parse import urljoin
7import httpx
8from integrify.logger import LOGGER_FUNCTION
9from integrify.schemas import APIResponse, DryResponse, PayloadBaseModel
10from integrify.utils import UNSET, _ResponseT
12DEFAULT_TIMEOUT = 10
13"""Default sorğu timeout-u (saniyə ilə)"""
16# ------------------------------------------------------------------------------------------------ #
17# Sync/async üçün tip markerləri #
18# ------------------------------------------------------------------------------------------------ #
19# Bunlar yalnız tip səviyyəsində istifadə olunur (runtime-da heç bir rol oynamır). İnteqrasiya
20# klientləri `Generic[_Mode]` ilə parametrləşdirilib, hər metod üçün iki `@overload` təyin edir:
21# `self: XClientClass[_Sync]` -> sync qaytarış (APIResponse[...])
22# `self: XClientClass[_Async]` -> async qaytarış (Coroutine[Any, Any, APIResponse[...]])
23# Beləliklə, sync və async klientlər eyni docstring-i paylaşır,
24# lakin düzgün qaytarış tipinə malikdir.
27class _Sync: # pylint: disable=too-few-public-methods
28 """Sync klient rejimi üçün tip markeri (yalnız type-checking)."""
31class _Async: # pylint: disable=too-few-public-methods
32 """Async klient rejimi üçün tip markeri (yalnız type-checking)."""
35_Mode = TypeVar('_Mode')
36"""İnteqrasiya klientlərinin sync/async rejimini bildirən TypeVar (`_Sync` və ya `_Async`)."""
39class APIClient:
40 """
41 API inteqrasiyaları üçün klient
42 """
44 def __init__(
45 self,
46 name: str,
47 base_url: str | None = None,
48 default_handler: Optional['APIPayloadHandler'] = None,
49 sync: bool = True,
50 dry: bool = False,
51 timeout: float | None = DEFAULT_TIMEOUT,
52 ):
53 """
54 Args:
55 name: Klient adı. Logging üçün istifadə olunur.
56 base_url: API-lərin əsas (kök) url-i. Əgər bir neçə base_url varsa, bu field-i
57 boş saxlayıb, hər endpoint-ə uyğun base_url-i `add_url` funksiyasında
58 verin. (bax: AzeriCard)
59 default_handler: default API handler. Bu handler əgər hər hansı bir API-yə
60 handler register olunmadıqda istifadə olunur.
61 sync: Sync (True) və ya Async (False) klient seçimi. Default olaraq sync seçilir.
62 dry: Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq üçün istifadə olunur.
63 timeout: httpx sorğu timeout-u (saniyə ilə).
64 """
65 self.base_url = base_url
66 self.default_handler = default_handler or APIPayloadHandler(None, None)
68 self.request_executor = APIExecutor(name=name, sync=sync, dry=dry, timeout=timeout)
69 """API sorğularını icra edən obyekt"""
71 self.urls: dict[str, dict[str, str]] = {}
72 """API sorğularının endpoint və metodunun mapping-i"""
74 self.handlers: dict[str, APIPayloadHandler] = {}
75 """API sorğularının payload (request və response) handler-lərının mapping-i"""
77 def add_url(self, route_name: str, url: str, verb: str, base_url: str | None = None) -> None:
78 """Yeni endpoint əlavə etmə funksiyası
80 Args:
81 route_name: Funksionallığın adı (məs., `pay`, `refund` və s.)
82 url: Endpoint url-i
83 verb: Endpoint metodu (`POST`, `GET`, və s.)
84 base_url: Endpoint-lərin baza (kök) url-i. Endpoint-lər fərqli hostlar
85 üzərində qurulduqda lazım olur.
86 """
87 self.urls[route_name] = {'url': url, 'verb': verb}
89 # Əgər inteqrasiyanın bütün endpoint-ləri bir base_url-də deyilsə,
90 # endpointləri, `base_url` ilə əlavə etmək lazımdır.
91 self.urls[route_name]['base_url'] = base_url or self.base_url or ''
93 def set_default_handler(self, handler_class: type['APIPayloadHandler']) -> None:
94 """Sorğulara default handler setter-i
96 Args:
97 handler_class: Default handler class-ı
98 """
99 self.default_handler = handler_class() # pragma: no cover
101 def add_handler(self, route_name: str, handler_class: type['APIPayloadHandler']) -> None:
102 """Endpoint-ə handler əlavə etmək method-u
104 Args:
105 route_name: Funksionallığın adı (məs., `pay`, `refund` və s.)
106 handler_class: Həmin sorğunun (və response-unun) payload handler class-ı
107 """
108 self.handlers[route_name] = handler_class()
110 def close(self) -> None:
111 """Sync klientin bağlanması (httpx connection pool-un boşaldılması)"""
112 self.request_executor.close()
114 async def aclose(self) -> None:
115 """Async klientin bağlanması (httpx connection pool-un boşaldılması)"""
116 await self.request_executor.aclose()
118 def __enter__(self) -> 'APIClient':
119 return self
121 def __exit__(self, *exc: Any) -> None:
122 self.close()
124 async def __aenter__(self) -> 'APIClient':
125 return self
127 async def __aexit__(self, *exc: Any) -> None:
128 await self.aclose()
130 def _build_request_lambda(
131 self,
132 func: Callable,
133 url: str,
134 verb: str,
135 handler: 'APIPayloadHandler',
136 ) -> Callable:
137 return lambda *args, **kwds: func(
138 url,
139 verb,
140 handler,
141 *(arg for arg in args if arg is not UNSET),
142 **{k: v for k, v in kwds.items() if v is not UNSET},
143 )
145 def __getattr__(self, name: str) -> Any:
146 """Möcüzənin baş verdiyi yer:
148 Bu kitanxanada, heç bir inteqrasiya üçün birbaşa funksiya mövcud deyil. Bunun yerinə,
149 bu dunder metodundan istifadə edərək, hansı endpointə nə sorğu atılacağını anlaya bilirik.
151 `__getattr__` yalnız adi atribut axtarışı uğursuz olduqda çağırılır, ona görə də
152 real atributlara (`urls`, `handlers` və s.) heç bir əlavə yük gətirmir.
153 """
154 # `self.urls`-ə birbaşa müraciət rekursiyaya səbəb ola bilər (əgər hələ init
155 # olunmayıbsa), ona görə `__dict__`-dən oxuyuruq.
156 urls = self.__dict__.get('urls')
158 # "Axtarılan" funksiyanın adı `self.urls` listimizdə mövcud deyilsə, exception qaldırırıq
159 if not urls or name not in urls:
160 raise AttributeError(name)
162 # "Axtarılan" funksiyanın adından istifadə edərək, lazımi endpoint, metod və handler-i
163 # taparaq, sorğunu icra edirik.
164 base_url = urls[name]['base_url']
165 url = urljoin(base_url, urls[name]['url'])
166 verb = urls[name]['verb']
167 handler = self.handlers.get(name, self.default_handler)
169 func = self.request_executor.request_function
170 return self._build_request_lambda(func, url, verb, handler)
173class APIPayloadHandler:
174 """Sorğu və cavab data payload-ları üçün handler class-ı
176 Handler-lər **stateless**-dir: sorğu modeli heç vaxt `self`-də saxlanılmır, hər
177 çağırışda lokal olaraq yaradılıb ötürülür. Bu, eyni handler instansiyasının
178 (məs., `EPointAsyncRequest` kimi shared klientlərdə) paralel sorğularda təhlükəsiz
179 istifadəsinə imkan verir.
181 `req_model`, `resp_model` və `dry` class atribut (ClassVar) kimi təyin oluna bilər::
183 class MyHandler(APIPayloadHandler):
184 req_model = MyRequestSchema
185 resp_model = MyResponseSchema
187 və ya geriyə uyğunluq üçün `__init__`-ə ötürülə bilər.
188 """
190 req_model: ClassVar[type[PayloadBaseModel] | None] = None
191 """Sorğunun payload model-i"""
193 resp_model: ClassVar[Any] = dict
194 """Sorğunun cavabının payload model-i"""
196 dry: ClassVar[bool] = False
197 """Simulasiya bool-u: True olarsa, sorğu göndərilmir, göndərilən data qaytarılır"""
199 def __init__(
200 self,
201 req_model: Any = UNSET,
202 resp_model: Any = UNSET,
203 dry: Any = UNSET,
204 ):
205 """
206 Args:
207 req_model: Sorğunun payload model-i. Verilməsə, class atributu istifadə olunur.
208 resp_model: Sorğunun cavabının payload model-i. Verilməsə, class atributu istifadə olunur.
209 dry: Simulasiya bool-u. Verilməsə, class atributu istifadə olunur.
210 """ # noqa: E501
211 # Geriyə uyğunluq: dəyər `__init__`-ə ötürülübsə, instansiya səviyyəsində
212 # class atributunu override edirik; əks halda ClassVar dəyəri qalır.
213 if req_model is not UNSET:
214 self.req_model = req_model
215 if resp_model is not UNSET:
216 self.resp_model = resp_model
217 if dry is not UNSET:
218 self.dry = dry
220 def build_request_model(self, *args, **kwds) -> PayloadBaseModel | None:
221 """Verilən argumentlərdən `self.req_model` instansiyasını yaradan funksiya.
223 Model heç bir instansiya state-i saxlamadan qaytarılır (thread/async safe).
224 """
225 if self.req_model:
226 return self.req_model.from_args(*args, **kwds)
228 return None
230 def set_urlparams(self, url: str, req_model: PayloadBaseModel | None = None) -> str:
231 """URL-in query-param-larını set etmək üçün funksiya (əgər varsa)
233 Args:
234 url: Format olunmalı url
235 req_model: `build_request_model`-dan qayıdan model instansiyası
236 """
237 if not (self.req_model and self.req_model.URL_PARAM_FIELDS and req_model):
238 if any(tup[1] for tup in string.Formatter().parse(url) if tup[1] is not None):
239 raise ValueError('URL should not expect any arguments')
241 return url
243 return url.format(
244 **req_model.model_dump(
245 by_alias=True,
246 include=self.req_model.URL_PARAM_FIELDS,
247 exclude_none=True,
248 mode='json',
249 )
250 )
252 @cached_property
253 def headers(self) -> dict:
254 """Sorğunun header-ləri"""
255 return {'Content-Type': 'application/json'}
257 @cached_property
258 def req_args(self) -> dict:
259 """Request funksiyası üçün əlavə parametrlər"""
260 return {}
262 def pre_handle_payload(self, *args, **kwds):
263 """Sorğunun payload-ının pre-processing-i. Əgər istənilən payload-a
264 əlavə datanı lazımdırsa (bütün sorğularda eyni olan data), bu funksiyadan
265 istifadə edə bilərsiniz.
267 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
268 """
270 def handle_payload(self, req_model: PayloadBaseModel | None, *args, **kwds):
271 """Verilən sorğu modelini payload-a (dict) çevirən funksiya.
272 `self.req_model` qeyd edilməyibsə, bu funksiya override olunmalıdır (!).
274 Args:
275 req_model: `build_request_model`-dan qayıdan model instansiyası
276 """
277 if self.req_model and req_model is not None:
278 return req_model.model_dump(
279 by_alias=True,
280 exclude=self.req_model.URL_PARAM_FIELDS,
281 mode='json',
282 )
284 # `req_model` yoxdursa, o zaman `*args` boş olmalıdır, çünki onların key-ləri bilinmir
285 assert not args
287 return kwds
289 def post_handle_payload(self, data: Any):
290 """Sorğunun payload-ının post-processing-i. Əgər sorğu göndərməmişdən qabaq
291 son datanın üzərinə əlavələr lazımdırsa, bu funksiyadan istifadə edə bilərsiniz.
293 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
295 Args:
296 data: `pre_handle_payload` və `handle_payload` funksiyalarından yaradılmış data.
297 """
298 return data # pragma: no cover
300 def handle_request(self, req_model: PayloadBaseModel | None, *args, **kwds):
301 """Sorğu üçün payload-u hazırlayan funksiya. Üç mərhələ icra edir,
302 və bu mərhələlər override oluna bilər. (Misal üçün:
303 Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
305 1. Pre-processing
306 2. Payload hazırlama
307 3. Post-processing
309 Args:
310 req_model: `build_request_model`-dan qayıdan model instansiyası
311 """
312 pre_data = self.pre_handle_payload(*args, **kwds) or {}
313 data = {**pre_data, **self.handle_payload(req_model, *args, **kwds)}
314 return self.post_handle_payload(data)
316 def handle_response(
317 self,
318 resp: httpx.Response,
319 ) -> APIResponse[_ResponseT] | httpx.Response:
320 """Sorğudan gələn cavab payload-ı handle edən funksiya. `self.resp_model` schema-sı
321 verilibsə, onunla parse və validate olunur, əks halda, json/dict formatında qaytarılır.
322 """
323 if not self.resp_model:
324 return resp
326 return APIResponse[self.resp_model].model_validate(resp, from_attributes=True)
329class APIExecutor:
330 """API sorgularını icra edən class"""
332 def __init__(
333 self,
334 name: str,
335 sync: bool = True,
336 dry: bool = False,
337 timeout: float | None = DEFAULT_TIMEOUT,
338 ):
339 """
340 Args:
341 name: API klientin adı. Logging üçün istifadə olunur.
342 sync: Sync (True) və ya Async (False) klient seçimi. Default olaraq sync seçilir.
343 dry: Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq üçün istifadə olunur.
344 Debug üçün nəzərdə tutulub.
345 timeout: httpx sorğu timeout-u (saniyə ilə).
346 """
347 self.sync = sync
348 self.dry = dry
349 self.timeout = timeout
350 self.client_name = name
351 self.logger = LOGGER_FUNCTION(name)
353 @cached_property
354 def client(self) -> httpx.Client | httpx.AsyncClient:
355 """httpx sorğu client-i.
357 Lazy yaradılır: import zamanı deyil, ilk sorğuda açılır. Beləliklə, `AsyncClient`
358 event loop-dan kənarda import zamanı yaradılmır və heç istifadə olunmayan
359 klient üçün socket açılmır.
360 """
361 if self.sync: 361 ↛ 364line 361 didn't jump to line 364 because the condition on line 361 was always true
362 return httpx.Client(timeout=self.timeout)
364 return httpx.AsyncClient(timeout=self.timeout)
366 def close(self) -> None:
367 """Sync httpx client-in bağlanması (əgər yaradılıbsa)"""
368 client = self.__dict__.get('client')
369 if client is not None and isinstance(client, httpx.Client):
370 client.close()
371 del self.__dict__['client']
373 async def aclose(self) -> None:
374 """Async httpx client-in bağlanması (əgər yaradılıbsa)"""
375 client = self.__dict__.get('client')
376 if client is not None and isinstance(client, httpx.AsyncClient):
377 await client.aclose()
378 del self.__dict__['client']
380 @property
381 def request_function(
382 self,
383 ) -> Callable[
384 [str, str, APIPayloadHandler, Any], # input args
385 httpx.Response
386 | APIResponse[_ResponseT]
387 | DryResponse
388 | Coroutine[Any, Any, httpx.Response | APIResponse[_ResponseT] | DryResponse], # output
389 ]:
390 """Sync/async request atan funksiyanı seçən attribute"""
391 if self.sync:
392 return self.sync_req
394 return self.async_req # pragma: no cover
396 def sync_req(
397 self,
398 url: str,
399 verb: str,
400 handler: APIPayloadHandler,
401 *args,
402 headers: dict | None = None,
403 **kwds,
404 ) -> httpx.Response | APIResponse[_ResponseT] | DryResponse:
405 """Sync sorğu atan funksiya
407 Args:
408 url: Sorğunun full url-i
409 verb: Sorğunun metodun (`POST`, `GET`, və s.)
410 handler: Sorğu və cavabın payload handler-i
411 """
412 assert isinstance(self.client, httpx.Client)
414 req_model = handler.build_request_model(*args, **kwds)
415 data = handler.handle_request(req_model, *args, **kwds)
416 full_headers = {**handler.headers, **(headers or {})}
417 full_url = handler.set_urlparams(url, req_model)
419 if self.dry or handler.dry:
420 return DryResponse(
421 url=full_url,
422 verb=verb,
423 request_args=handler.req_args,
424 headers=full_headers,
425 data=data,
426 )
428 request_kwds = {'headers': full_headers, **handler.req_args}
430 if verb == 'GET':
431 request_kwds['params'] = data
432 else:
433 request_kwds['json'] = data
435 response = self.client.request(verb, full_url, **request_kwds)
437 if not response.is_success:
438 self.logger.error(
439 '%s request to %s failed. Status code was %d. Content => %s',
440 self.client_name,
441 full_url,
442 response.status_code,
443 response.content.decode(errors='replace'),
444 )
446 return handler.handle_response(response)
448 async def async_req( # pragma: no cover
449 self,
450 url: str,
451 verb: str,
452 handler: APIPayloadHandler,
453 *args,
454 headers: dict | None = None,
455 **kwds,
456 ) -> httpx.Response | APIResponse[_ResponseT] | DryResponse:
457 """Async sorğu atan funksiya
459 Args:
460 url: Sorğunun full url-i
461 verb: Sorğunun metodun (`POST`, `GET`, və s.)
462 handler: Sorğu və cavabın payload handler-i
463 """
464 assert isinstance(self.client, httpx.AsyncClient)
466 req_model = handler.build_request_model(*args, **kwds)
467 data = handler.handle_request(req_model, *args, **kwds)
468 full_headers = {**handler.headers, **(headers or {})}
469 full_url = handler.set_urlparams(url, req_model)
471 if self.dry or handler.dry:
472 # Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq
473 return DryResponse(
474 url=full_url,
475 verb=verb,
476 request_args=handler.req_args,
477 headers=full_headers,
478 data=data,
479 )
481 request_kwds = {'headers': full_headers, **handler.req_args}
483 if verb == 'GET':
484 request_kwds['params'] = data
485 else:
486 request_kwds['json'] = data
488 response = await self.client.request(verb, full_url, **request_kwds)
490 if not response.is_success:
491 self.logger.error(
492 '%s request to %s failed. Status code was %d. Content => %s',
493 self.client_name,
494 full_url,
495 response.status_code,
496 response.content.decode(errors='replace'),
497 )
499 return handler.handle_response(response)