Coverage for src/integrify/api.py: 100%
94 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-10 00:57 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-10 00:57 +0000
1import json
2import string
3from functools import cached_property
4from typing import Any, Callable, Coroutine, Optional, Union
5from urllib.parse import urljoin
7import httpx
9from integrify.logger import LOGGER_FUNCTION
10from integrify.schemas import APIResponse, PayloadBaseModel, _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.
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 metodunun (`POST`, `GET`, və s.)
55 """
56 self.urls[route_name] = {'url': url, 'verb': verb}
58 # Əgər inteqrasiyanın bütün endpoint-ləri bir base_url-də deyilsə,
59 # endpointləri, `base_url` ilə əlavə etmək lazımdır.
60 if base_url:
61 self.urls[route_name]['base_url'] = base_url
63 def set_default_handler(self, handler_class: type['APIPayloadHandler']) -> None:
64 """Sorğulara default handler setter-i
66 Args:
67 handler_class: Default handler class-ı
68 """
69 self.default_handler = handler_class() # pragma: no cover
71 def add_handler(self, route_name: str, handler_class: type['APIPayloadHandler']) -> None:
72 """Endpoint-ə handler əlavə etmək method-u
74 Args:
75 route_name: Funksionallığın adı (məs., `pay`, `refund` və s.)
76 handler_class: Həmin sorğunun (və response-unun) payload handler class-ı
77 """
78 self.handlers[route_name] = handler_class()
80 def __getattribute__(self, name: str) -> Any:
81 """Möcüzənin baş verdiyi yer:
83 Bu kitanxanada, heç bir inteqrasiya üçün birbaşa funksiya mövcud deyil. Bunun yerinə,
84 bu dunder metodundan istifadə edərək, hansı endpointə nə sorğu atılacağını anlaya bilirik.
85 """
86 try:
87 return super().__getattribute__(name)
88 except AttributeError:
89 # Əgər "axtarılan" funksiyanın adı `self.urls` listimizdə mövcud deyilsə,
90 # exception qaldırırıq
91 if name not in self.urls:
92 raise
94 # "Axtarılan" funksiyanın adından istifadə edərək, lazımi endpoint, metod və handler-i
95 # taparaq, sorğunu icra edirik.
96 base_url = self.base_url or self.urls[name]['base_url']
97 url = urljoin(base_url, self.urls[name]['url'])
98 verb = self.urls[name]['verb']
99 handler = self.handlers.get(name, self.default_handler)
101 func = self.request_executor.request_function
102 return lambda *args, **kwds: func(url, verb, handler, *args, **kwds)
105class APIPayloadHandler:
106 """Sorğu və cavab data payload-ları üçün handler class-ı"""
108 def __init__(
109 self,
110 req_model: Optional[type[PayloadBaseModel]] = None,
111 resp_model: Union[type[_ResponseT], type[dict], None] = dict,
112 dry: bool = False,
113 ):
114 """
115 Args:
116 req_model: Sorğunun payload model-i
117 resp_model: Sorğunun cavabının payload model-i
118 dry: Simulasiya bool-u: True olarsa, sorğu göndərilmir, göndərilən data qaytarılır
119 """
120 self.req_model = req_model
121 self.__req_model: Optional[PayloadBaseModel] = None # initialized pydantic model
122 self.resp_model = resp_model
123 self.dry = dry
125 def set_urlparams(self, url: str) -> str:
126 """URL-in query-param-larını set etmək üçün funksiya (əgər varsa)
128 Args:
129 url: Format olunmalı url
130 """
131 if not (self.req_model and self.req_model.URL_PARAM_FIELDS and self.__req_model):
132 if any(tup[1] for tup in string.Formatter().parse(url) if tup[1] is not None):
133 raise ValueError('URL should not expect any arguments')
135 return url
137 return url.format(
138 **self.__req_model.model_dump(
139 by_alias=True,
140 include=self.req_model.URL_PARAM_FIELDS,
141 exclude_none=True,
142 mode='json',
143 )
144 )
146 @cached_property
147 def headers(self) -> dict:
148 """Sorğunun header-ləri"""
149 return {}
151 @cached_property
152 def req_args(self) -> dict:
153 """Request funksiyası üçün əlavə parametrlər"""
154 return {}
156 def pre_handle_payload(self, *args, **kwds):
157 """Sorğunun payload-ının pre-processing-i. Əgər istənilən payload-a
158 əlavə datanı lazımdırsa (bütün sorğularda eyni olan data), bu funksiyadan
159 istifadə edə bilərsiniz.
161 Misal üçün: Bax [`EPointClientClass`][integrify.epoint.client.EPointClientClass]
162 """
164 def handle_payload(self, *args, **kwds):
165 """Verilən argumentləri `self.req_model` formatında payload-a çevirən funksiya.
166 `self.req_model` qeyd edilməyibsə, bu funksiya override olunmalıdır (!).
167 """
168 if self.req_model:
169 self.__req_model = self.req_model.from_args(*args, **kwds)
170 return self.__req_model.model_dump(
171 by_alias=True,
172 exclude=self.req_model.URL_PARAM_FIELDS,
173 exclude_none=True,
174 mode='json',
175 )
177 # `req_model` yoxdursa, o zaman `*args` boş olmalıdır, çünki onların key-ləri bilinmir
178 assert not args
180 return kwds
182 def post_handle_payload(self, data: Any):
183 """Sorğunun payload-ının post-processing-i. Əgər sorğu göndərməmişdən qabaq
184 son datanın üzərinə əlavələr lazımdırsa, bu funksiyadan istifadə edə bilərsiniz.
186 Misal üçün: Bax [`EPointClientClass`][integrify.epoint.client.EPointClientClass]
188 Args:
189 data: `pre_handle_payload` və `handle_payload` funksiyalarından yaradılmış data.
190 """
191 return data # pragma: no cover
193 def handle_request(self, *args, **kwds):
194 """Sorğu üçün payload-u hazırlayan funksiya. Üç mərhələ icra edir,
195 və bu mərhələlər override oluna bilər. (Misal üçün:
196 Bax [`EPointClientClass`][integrify.epoint.client.EPointClientClass])
198 1. Pre-processing
199 2. Payload hazırlama
200 3. Post-processing
201 """
203 pre_data = self.pre_handle_payload(*args, **kwds) or {}
204 data = {**pre_data, **self.handle_payload(*args, **kwds)}
205 return self.post_handle_payload(data)
207 def handle_response(
208 self,
209 resp: httpx.Response,
210 ) -> Union[APIResponse[_ResponseT], APIResponse[dict], httpx.Response]:
211 """Sorğudan gələn cavab payload-ı handle edən funksiya. `self.resp_model` schema-sı
212 verilibsə, onunla parse və validate olunur, əks halda, json/dict formatında qaytarılır.
213 """
214 if not self.resp_model:
215 return resp
217 return APIResponse[self.resp_model].model_validate(resp, from_attributes=True) # type: ignore[name-defined]
220class APIExecutor:
221 """API sorgularını icra edən class"""
223 def __init__(self, name: str, sync: bool = True, dry: bool = False):
224 """
225 Args:
226 name: API klientin adı. Logging üçün istifadə olunur.
227 sync: Sync (True) və ya Async (False) klient seçimi. Default olaraq sync seçilir.
228 dry: Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq üçün istifadə olunur.
229 Debug üçün nəzərdə tutulub.
230 """
231 self.sync = sync
232 self.dry = dry
233 self.client_name = name
234 self.logger = LOGGER_FUNCTION(name)
236 self.client: Union[httpx.Client, httpx.AsyncClient]
237 """httpx sorğu client-i"""
239 if sync:
240 self.client = httpx.Client(timeout=10)
241 else:
242 self.client = httpx.AsyncClient(timeout=10)
244 @property
245 def request_function(
246 self,
247 ) -> Callable[
248 [str, str, APIPayloadHandler, Any], # input args
249 Union[
250 Union[httpx.Response, APIResponse[_ResponseT], APIResponse[dict]],
251 Coroutine[
252 Any,
253 Any,
254 Union[httpx.Response, APIResponse[_ResponseT], APIResponse[dict]],
255 ],
256 ], # output
257 ]:
258 """Sync/async request atan funksiyanı seçən attribute"""
259 if self.sync:
260 return self.sync_req
262 return self.async_req # pragma: no cover
264 def sync_req(
265 self,
266 url: str,
267 verb: str,
268 handler: APIPayloadHandler,
269 *args,
270 **kwds,
271 ) -> Union[httpx.Response, APIResponse[_ResponseT], APIResponse[dict]]:
272 """Sync sorğu atan funksiya
274 Args:
275 url: Sorğunun full url-i
276 verb: Sorğunun metodun (`POST`, `GET`, və s.)
277 handler: Sorğu və cavabın payload handler-i
278 """
279 assert isinstance(self.client, httpx.Client)
281 data = handler.handle_request(*args, **kwds)
282 headers = handler.headers
283 full_url = handler.set_urlparams(url)
285 if self.dry or handler.dry:
286 return APIResponse[dict]( # type: ignore[valid-type, call-arg]
287 is_success=True,
288 status_code=200,
289 headers=headers or {},
290 content=json.dumps({**data, 'url': full_url}),
291 )
293 response = self.client.request(
294 verb,
295 full_url,
296 data=data,
297 headers=headers,
298 **handler.req_args,
299 )
301 if not response.is_success:
302 self.logger.error(
303 '%s request to %s failed. Status code was %d. Content => %s',
304 self.client_name,
305 url,
306 response.status_code,
307 response.content.decode(),
308 )
310 return handler.handle_response(response)
312 async def async_req( # pragma: no cover
313 self,
314 url: str,
315 verb: str,
316 handler: APIPayloadHandler,
317 *args,
318 **kwds,
319 ) -> Union[httpx.Response, APIResponse[_ResponseT], APIResponse[dict]]:
320 """Async sorğu atan funksiya
322 Args:
323 url: Sorğunun full url-i
324 verb: Sorğunun metodun (`POST`, `GET`, və s.)
325 handler: Sorğu və cavabın payload handler-i
326 """
327 assert isinstance(self.client, httpx.AsyncClient)
329 data = handler.handle_request(*args, **kwds)
330 headers = handler.headers
331 full_url = handler.set_urlparams(url)
333 if self.dry: # Sorğu göndərmək əvəzinə göndəriləcək datanı qaytarmaq
334 return APIResponse[dict]( # type: ignore[valid-type, call-arg]
335 is_success=True,
336 status_code=200,
337 headers=headers or {},
338 content=json.dumps({**data, 'url': full_url}),
339 )
341 response = await self.client.request(
342 verb,
343 full_url,
344 data=data,
345 headers=headers,
346 **handler.req_args,
347 )
349 if not response.is_success:
350 self.logger.error(
351 '%s request to %s failed. Status code was %d. Content => %s',
352 self.client_name,
353 url,
354 response.status_code,
355 response.content.decode(),
356 )
358 return handler.handle_response(response)