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

1import string 

2from functools import cached_property 

3from typing import Any, Callable, Coroutine, Optional, Union 

4from urllib.parse import urljoin 

5 

6import httpx 

7 

8from integrify.logger import LOGGER_FUNCTION 

9from integrify.schemas import APIResponse, DryResponse, PayloadBaseModel 

10from integrify.utils import UNSET, _ResponseT 

11 

12 

13class APIClient: 

14 """ 

15 API inteqrasiyaları üçün klient 

16 """ 

17 

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) 

38 

39 self.request_executor = APIExecutor(name=name, sync=sync, dry=dry) 

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

41 

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

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

44 

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

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

47 

48 def add_url(self, route_name: str, url: str, verb: str, base_url: Optional[str] = None) -> None: 

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

50 

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} 

59 

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

63 

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

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

66 

67 Args: 

68 handler_class: Default handler class-ı 

69 """ 

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

71 

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

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

74 

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

80 

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 ) 

95 

96 def __getattribute__(self, name: str) -> Any: 

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

98 

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 

109 

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) 

116 

117 func = self.request_executor.request_function 

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

119 

120 

121class APIPayloadHandler: 

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

123 

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 

140 

141 def set_urlparams(self, url: str) -> str: 

142 """URL-in query-param-larını set etmək üçün funksiya (əgər varsa) 

143 

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

150 

151 return url 

152 

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 ) 

161 

162 @cached_property 

163 def headers(self) -> dict: 

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

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

166 

167 @cached_property 

168 def req_args(self) -> dict: 

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

170 return {} 

171 

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. 

176 

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

178 """ 

179 

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 ) 

191 

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

193 assert not args 

194 

195 return kwds 

196 

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. 

200 

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

202 

203 Args: 

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

205 """ 

206 return data # pragma: no cover 

207 

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) 

212 

213 1. Pre-processing 

214 2. Payload hazırlama 

215 3. Post-processing 

216 """ 

217 

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) 

221 

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 

231 

232 return APIResponse[self.resp_model].model_validate(resp, from_attributes=True) # type: ignore[name-defined] 

233 

234 

235class APIExecutor: 

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

237 

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) 

250 

251 self.client: Union[httpx.Client, httpx.AsyncClient] 

252 """httpx sorğu client-i""" 

253 

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) 

258 

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 

276 

277 return self.async_req # pragma: no cover 

278 

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 

289 

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) 

296 

297 data = handler.handle_request(*args, **kwds) 

298 full_headers = {**handler.headers, **(headers or {})} 

299 full_url = handler.set_urlparams(url) 

300 

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 ) 

309 

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

311 

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 

316 

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

318 

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 ) 

327 

328 return handler.handle_response(response) 

329 

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 

340 

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) 

347 

348 data = handler.handle_request(*args, **kwds) 

349 full_headers = {**handler.headers, **(headers or {})} 

350 full_url = handler.set_urlparams(url) 

351 

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 ) 

361 

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

363 

364 if verb == 'GET': 

365 request_kwds['params'] = data 

366 else: 

367 request_kwds['json'] = data 

368 

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

370 

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 ) 

379 

380 return handler.handle_response(response)