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

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 

6 

7import httpx 

8from integrify.logger import LOGGER_FUNCTION 

9from integrify.schemas import APIResponse, DryResponse, PayloadBaseModel 

10from integrify.utils import UNSET, _ResponseT 

11 

12DEFAULT_TIMEOUT = 10 

13"""Default sorğu timeout-u (saniyə ilə)""" 

14 

15 

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. 

25 

26 

27class _Sync: # pylint: disable=too-few-public-methods 

28 """Sync klient rejimi üçün tip markeri (yalnız type-checking).""" 

29 

30 

31class _Async: # pylint: disable=too-few-public-methods 

32 """Async klient rejimi üçün tip markeri (yalnız type-checking).""" 

33 

34 

35_Mode = TypeVar('_Mode') 

36"""İnteqrasiya klientlərinin sync/async rejimini bildirən TypeVar (`_Sync` və ya `_Async`).""" 

37 

38 

39class APIClient: 

40 """ 

41 API inteqrasiyaları üçün klient 

42 """ 

43 

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) 

67 

68 self.request_executor = APIExecutor(name=name, sync=sync, dry=dry, timeout=timeout) 

69 """API sorğularını icra edən obyekt""" 

70 

71 self.urls: dict[str, dict[str, str]] = {} 

72 """API sorğularının endpoint və metodunun mapping-i""" 

73 

74 self.handlers: dict[str, APIPayloadHandler] = {} 

75 """API sorğularının payload (request və response) handler-lərının mapping-i""" 

76 

77 def add_url(self, route_name: str, url: str, verb: str, base_url: str | None = None) -> None: 

78 """Yeni endpoint əlavə etmə funksiyası 

79 

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} 

88 

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 '' 

92 

93 def set_default_handler(self, handler_class: type['APIPayloadHandler']) -> None: 

94 """Sorğulara default handler setter-i 

95 

96 Args: 

97 handler_class: Default handler class-ı 

98 """ 

99 self.default_handler = handler_class() # pragma: no cover 

100 

101 def add_handler(self, route_name: str, handler_class: type['APIPayloadHandler']) -> None: 

102 """Endpoint-ə handler əlavə etmək method-u 

103 

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() 

109 

110 def close(self) -> None: 

111 """Sync klientin bağlanması (httpx connection pool-un boşaldılması)""" 

112 self.request_executor.close() 

113 

114 async def aclose(self) -> None: 

115 """Async klientin bağlanması (httpx connection pool-un boşaldılması)""" 

116 await self.request_executor.aclose() 

117 

118 def __enter__(self) -> 'APIClient': 

119 return self 

120 

121 def __exit__(self, *exc: Any) -> None: 

122 self.close() 

123 

124 async def __aenter__(self) -> 'APIClient': 

125 return self 

126 

127 async def __aexit__(self, *exc: Any) -> None: 

128 await self.aclose() 

129 

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 ) 

144 

145 def __getattr__(self, name: str) -> Any: 

146 """Möcüzənin baş verdiyi yer: 

147 

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. 

150 

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') 

157 

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) 

161 

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) 

168 

169 func = self.request_executor.request_function 

170 return self._build_request_lambda(func, url, verb, handler) 

171 

172 

173class APIPayloadHandler: 

174 """Sorğu və cavab data payload-ları üçün handler class-ı 

175 

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. 

180 

181 `req_model`, `resp_model` və `dry` class atribut (ClassVar) kimi təyin oluna bilər:: 

182 

183 class MyHandler(APIPayloadHandler): 

184 req_model = MyRequestSchema 

185 resp_model = MyResponseSchema 

186 

187 və ya geriyə uyğunluq üçün `__init__`-ə ötürülə bilər. 

188 """ 

189 

190 req_model: ClassVar[type[PayloadBaseModel] | None] = None 

191 """Sorğunun payload model-i""" 

192 

193 resp_model: ClassVar[Any] = dict 

194 """Sorğunun cavabının payload model-i""" 

195 

196 dry: ClassVar[bool] = False 

197 """Simulasiya bool-u: True olarsa, sorğu göndərilmir, göndərilən data qaytarılır""" 

198 

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 

219 

220 def build_request_model(self, *args, **kwds) -> PayloadBaseModel | None: 

221 """Verilən argumentlərdən `self.req_model` instansiyasını yaradan funksiya. 

222 

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) 

227 

228 return None 

229 

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) 

232 

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') 

240 

241 return url 

242 

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 ) 

251 

252 @cached_property 

253 def headers(self) -> dict: 

254 """Sorğunun header-ləri""" 

255 return {'Content-Type': 'application/json'} 

256 

257 @cached_property 

258 def req_args(self) -> dict: 

259 """Request funksiyası üçün əlavə parametrlər""" 

260 return {} 

261 

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. 

266 

267 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass) 

268 """ 

269 

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 (!). 

273 

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 ) 

283 

284 # `req_model` yoxdursa, o zaman `*args` boş olmalıdır, çünki onların key-ləri bilinmir 

285 assert not args 

286 

287 return kwds 

288 

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. 

292 

293 Misal üçün: Bax [`EPointClientClass`](https://integrify.mmzeynalli.dev/integrations/epoint/api-reference/client/#integrify.epoint.client.EPointClientClass) 

294 

295 Args: 

296 data: `pre_handle_payload` və `handle_payload` funksiyalarından yaradılmış data. 

297 """ 

298 return data # pragma: no cover 

299 

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) 

304 

305 1. Pre-processing 

306 2. Payload hazırlama 

307 3. Post-processing 

308 

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) 

315 

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 

325 

326 return APIResponse[self.resp_model].model_validate(resp, from_attributes=True) 

327 

328 

329class APIExecutor: 

330 """API sorgularını icra edən class""" 

331 

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) 

352 

353 @cached_property 

354 def client(self) -> httpx.Client | httpx.AsyncClient: 

355 """httpx sorğu client-i. 

356 

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) 

363 

364 return httpx.AsyncClient(timeout=self.timeout) 

365 

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'] 

372 

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'] 

379 

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 

393 

394 return self.async_req # pragma: no cover 

395 

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 

406 

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) 

413 

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) 

418 

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 ) 

427 

428 request_kwds = {'headers': full_headers, **handler.req_args} 

429 

430 if verb == 'GET': 

431 request_kwds['params'] = data 

432 else: 

433 request_kwds['json'] = data 

434 

435 response = self.client.request(verb, full_url, **request_kwds) 

436 

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 ) 

445 

446 return handler.handle_response(response) 

447 

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 

458 

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) 

465 

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) 

470 

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 ) 

480 

481 request_kwds = {'headers': full_headers, **handler.req_args} 

482 

483 if verb == 'GET': 

484 request_kwds['params'] = data 

485 else: 

486 request_kwds['json'] = data 

487 

488 response = await self.client.request(verb, full_url, **request_kwds) 

489 

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 ) 

498 

499 return handler.handle_response(response)