Coverage for src/integrify/api.py: 97%
99 statements
« prev ^ index » next coverage.py v7.6.12, created at 2026-01-19 02:27 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2026-01-19 02:27 +0000
1import string
2from functools import cached_property
3from typing import Any, Callable, Coroutine, Optional, Union
4from urllib.parse import urljoin
6import httpx
8from integrify.logger import LOGGER_FUNCTION
9from integrify.schemas import APIResponse, DryResponse, PayloadBaseModel
10from integrify.utils import UNSET, _ResponseT
13class APIClient:
14 """
15 API inteqrasiyaları üçün klient
16 """
18 def __init__(
19 self,
20 name: str,
21 base_url: Optional[str] = None,
22 default_handler: Optional['APIPayloadHandler'] = None,
23 sync: bool = True,
24 dry: bool = False,
25 ):
26 """
27 Args:
28 name: Klient adı. Logging üçün istifadə olunur.
29 base_url: API-lərin əsas (kök) url-i. Əgər bir neçə base_url varsa, bu field-i
30 boş saxlayıb, hər endpoint-ə uyğun base_url-i `add_url` funksiyasında
31 verin. (bax: AzeriCard)
32 default_handler: default API handler. Bu handler əgər hər hansı bir API-yə
33 handler register olunmadıqda istifadə olunur.
34 sync: Sync (True) və ya Async (False) klient seçimi. Default olaraq sync seçilir.
35 """
36 self.base_url = base_url
37 self.default_handler = default_handler or APIPayloadHandler(None, None)
39 self.request_executor = APIExecutor(name=name, sync=sync, dry=dry)
40 """API sorğularını icra edən obyekt"""
42 self.urls: dict[str, dict[str, str]] = {}
43 """API sorğularının endpoint və metodunun mapping-i"""
45 self.handlers: dict[str, APIPayloadHandler] = {}
46 """API sorğularının payload (request və response) handler-lərının mapping-i"""
48 def add_url(self, route_name: str, url: str, verb: str, base_url: Optional[str] = None) -> None:
49 """Yeni endpoint əlavə etmə funksiyası
51 Args:
52 route_name: Funksionallığın adı (məs., `pay`, `refund` və s.)
53 url: Endpoint url-i
54 verb: Endpoint metodu (`POST`, `GET`, və s.)
55 base_url: Endpoint-lərin baza (kök) url-i. Endpoint-lər fərqli hostlar
56 üzərində qurulduqda lazım olur.
57 """
58 self.urls[route_name] = {'url': url, 'verb': verb}
60 # Əgər inteqrasiyanın bütün endpoint-ləri bir base_url-də deyilsə,
61 # endpointləri, `base_url` ilə əlavə etmək lazımdır.
62 self.urls[route_name]['base_url'] = base_url or self.base_url or ''
64 def set_default_handler(self, handler_class: type['APIPayloadHandler']) -> None:
65 """Sorğulara default handler setter-i
67 Args:
68 handler_class: Default handler class-ı
69 """
70 self.default_handler = handler_class() # pragma: no cover
72 def add_handler(self, route_name: str, handler_class: type['APIPayloadHandler']) -> None:
73 """Endpoint-ə handler əlavə etmək method-u
75 Args:
76 route_name: Funksionallığın adı (məs., `pay`, `refund` və s.)
77 handler_class: Həmin sorğunun (və response-unun) payload handler class-ı
78 """
79 self.handlers[route_name] = handler_class()
81 def _build_request_lambda(
82 self,
83 func: Callable,
84 url: str,
85 verb: str,
86 handler: 'APIPayloadHandler',
87 ) -> Callable:
88 return lambda *args, **kwds: func(
89 url,
90 verb,
91 handler,
92 *(arg for arg in args if arg is not UNSET),
93 **{k: v for k, v in kwds.items() if v is not UNSET},
94 )
96 def __getattribute__(self, name: str) -> Any:
97 """Möcüzənin baş verdiyi yer:
99 Bu kitanxanada, heç bir inteqrasiya üçün birbaşa funksiya mövcud deyil. Bunun yerinə,
100 bu dunder metodundan istifadə edərək, hansı endpointə nə sorğu atılacağını anlaya bilirik.
101 """
102 try:
103 return super().__getattribute__(name)
104 except AttributeError:
105 # Əgər "axtarılan" funksiyanın adı `self.urls` listimizdə mövcud deyilsə,
106 # exception qaldırırıq
107 if name not in self.urls:
108 raise
110 # "Axtarılan" funksiyanın adından istifadə edərək, lazımi endpoint, metod və handler-i
111 # taparaq, sorğunu icra edirik.
112 base_url = self.urls[name]['base_url']
113 url = urljoin(base_url, self.urls[name]['url'])
114 verb = self.urls[name]['verb']
115 handler = self.handlers.get(name, self.default_handler)
117 func = self.request_executor.request_function
118 return self._build_request_lambda(func, url, verb, handler)
121class APIPayloadHandler:
122 """Sorğu və cavab data payload-ları üçün handler class-ı"""
124 def __init__(
125 self,
126 req_model: Optional[type[PayloadBaseModel]] = None,
127 resp_model: Union[type[_ResponseT], type[dict], None] = dict,
128 dry: bool = False,
129 ):
130 """
131 Args:
132 req_model: Sorğunun payload model-i
133 resp_model: Sorğunun cavabının payload model-i
134 dry: Simulasiya bool-u: True olarsa, sorğu göndərilmir, göndərilən data qaytarılır
135 """
136 self.req_model = req_model
137 self.__req_model: Optional[PayloadBaseModel] = None # initialized pydantic model
138 self.resp_model = resp_model
139 self.dry = dry
141 def set_urlparams(self, url: str) -> str:
142 """URL-in query-param-larını set etmək üçün funksiya (əgər varsa)
144 Args:
145 url: Format olunmalı url
146 """
147 if not (self.req_model and self.req_model.URL_PARAM_FIELDS and self.__req_model):
148 if any(tup[1] for tup in string.Formatter().parse(url) if tup[1] is not None):
149 raise ValueError('URL should not expect any arguments')
151 return url
153 return url.format(
154 **self.__req_model.model_dump(
155 by_alias=True,
156 include=self.req_model.URL_PARAM_FIELDS,
157 exclude_none=True,
158 mode='json',
159 )
160 )
162 @cached_property
163 def headers(self) -> dict:
164 """Sorğunun header-ləri"""
165 return {'Content-Type': 'application/json'}
167 @cached_property
168 def req_args(self) -> dict:
169 """Request funksiyası üçün əlavə parametrlər"""
170 return {}
172 def pre_handle_payload(self, *args, **kwds):
173 """Sorğunun payload-ının pre-processing-i. Əgər istənilən payload-a
174 əlavə datanı lazımdırsa (bütün sorğularda eyni olan data), bu funksiyadan
175 istifadə edə bilərsiniz.
177 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
178 """
180 def handle_payload(self, *args, **kwds):
181 """Verilən argumentləri `self.req_model` formatında payload-a çevirən funksiya.
182 `self.req_model` qeyd edilməyibsə, bu funksiya override olunmalıdır (!).
183 """
184 if self.req_model:
185 self.__req_model = self.req_model.from_args(*args, **kwds)
186 return self.__req_model.model_dump(
187 by_alias=True,
188 exclude=self.req_model.URL_PARAM_FIELDS,
189 mode='json',
190 )
192 # `req_model` yoxdursa, o zaman `*args` boş olmalıdır, çünki onların key-ləri bilinmir
193 assert not args
195 return kwds
197 def post_handle_payload(self, data: Any):
198 """Sorğunun payload-ının post-processing-i. Əgər sorğu göndərməmişdən qabaq
199 son datanın üzərinə əlavələr lazımdırsa, bu funksiyadan istifadə edə bilərsiniz.
201 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
203 Args:
204 data: `pre_handle_payload` və `handle_payload` funksiyalarından yaradılmış data.
205 """
206 return data # pragma: no cover
208 def handle_request(self, *args, **kwds):
209 """Sorğu üçün payload-u hazırlayan funksiya. Üç mərhələ icra edir,
210 və bu mərhələlər override oluna bilər. (Misal üçün:
211 Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass)
213 1. Pre-processing
214 2. Payload hazırlama
215 3. Post-processing
216 """
218 pre_data = self.pre_handle_payload(*args, **kwds) or {}
219 data = {**pre_data, **self.handle_payload(*args, **kwds)}
220 return self.post_handle_payload(data)
222 def handle_response(
223 self,
224 resp: httpx.Response,
225 ) -> Union[APIResponse[_ResponseT], httpx.Response]:
226 """Sorğudan gələn cavab payload-ı handle edən funksiya. `self.resp_model` schema-sı
227 verilibsə, onunla parse və validate olunur, əks halda, json/dict formatında qaytarılır.
228 """
229 if not self.resp_model:
230 return resp
232 return APIResponse[self.resp_model].model_validate(resp, from_attributes=True) # type: ignore[name-defined]
235class APIExecutor:
236 """API sorgularını icra edən class"""
238 def __init__(self, name: str, sync: bool = True, dry: bool = False):
239 """
240 Args:
241 name: API klientin adı. Logging üçün istifadə olunur.
242 sync: Sync (True) və ya Async (False) klient seçimi. Default olaraq sync seçilir.
243 dry: Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq üçün istifadə olunur.
244 Debug üçün nəzərdə tutulub.
245 """
246 self.sync = sync
247 self.dry = dry
248 self.client_name = name
249 self.logger = LOGGER_FUNCTION(name)
251 self.client: Union[httpx.Client, httpx.AsyncClient]
252 """httpx sorğu client-i"""
254 if sync: 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was always true
255 self.client = httpx.Client(timeout=10)
256 else:
257 self.client = httpx.AsyncClient(timeout=10)
259 @property
260 def request_function(
261 self,
262 ) -> Callable[
263 [str, str, APIPayloadHandler, Any], # input args
264 Union[
265 Union[httpx.Response, APIResponse[_ResponseT], DryResponse],
266 Coroutine[
267 Any,
268 Any,
269 Union[httpx.Response, APIResponse[_ResponseT], DryResponse],
270 ],
271 ], # output
272 ]:
273 """Sync/async request atan funksiyanı seçən attribute"""
274 if self.sync:
275 return self.sync_req
277 return self.async_req # pragma: no cover
279 def sync_req(
280 self,
281 url: str,
282 verb: str,
283 handler: APIPayloadHandler,
284 *args,
285 headers: Optional[dict] = None,
286 **kwds,
287 ) -> Union[httpx.Response, APIResponse[_ResponseT], DryResponse]:
288 """Sync sorğu atan funksiya
290 Args:
291 url: Sorğunun full url-i
292 verb: Sorğunun metodun (`POST`, `GET`, və s.)
293 handler: Sorğu və cavabın payload handler-i
294 """
295 assert isinstance(self.client, httpx.Client)
297 data = handler.handle_request(*args, **kwds)
298 full_headers = {**handler.headers, **(headers or {})}
299 full_url = handler.set_urlparams(url)
301 if self.dry or handler.dry:
302 return DryResponse(
303 url=full_url,
304 verb=verb,
305 request_args=handler.req_args,
306 headers=full_headers,
307 data=data,
308 )
310 request_kwds = {'headers': full_headers, **handler.req_args}
312 if verb == 'GET': 312 ↛ 315line 312 didn't jump to line 315 because the condition on line 312 was always true
313 request_kwds['params'] = data
314 else:
315 request_kwds['json'] = data
317 response = self.client.request(verb, full_url, **request_kwds)
319 if not response.is_success:
320 self.logger.error(
321 '%s request to %s failed. Status code was %d. Content => %s',
322 self.client_name,
323 url,
324 response.status_code,
325 response.content.decode(),
326 )
328 return handler.handle_response(response)
330 async def async_req( # pragma: no cover
331 self,
332 url: str,
333 verb: str,
334 handler: APIPayloadHandler,
335 *args,
336 headers: Optional[dict] = None,
337 **kwds,
338 ) -> Union[httpx.Response, APIResponse[_ResponseT], DryResponse]:
339 """Async sorğu atan funksiya
341 Args:
342 url: Sorğunun full url-i
343 verb: Sorğunun metodun (`POST`, `GET`, və s.)
344 handler: Sorğu və cavabın payload handler-i
345 """
346 assert isinstance(self.client, httpx.AsyncClient)
348 data = handler.handle_request(*args, **kwds)
349 full_headers = {**handler.headers, **(headers or {})}
350 full_url = handler.set_urlparams(url)
352 if self.dry:
353 # Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq
354 return DryResponse(
355 url=full_url,
356 verb=verb,
357 request_args=handler.req_args,
358 headers=full_headers,
359 data=data,
360 )
362 request_kwds = {'headers': full_headers, **handler.req_args}
364 if verb == 'GET':
365 request_kwds['params'] = data
366 else:
367 request_kwds['json'] = data
369 response = await self.client.request(verb, full_url, **request_kwds)
371 if not response.is_success:
372 self.logger.error(
373 '%s request to %s failed. Status code was %d. Content => %s',
374 self.client_name,
375 url,
376 response.status_code,
377 response.content.decode(),
378 )
380 return handler.handle_response(response)