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

1import json 

2import string 

3from functools import cached_property 

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

5from urllib.parse import urljoin 

6 

7import httpx 

8 

9from integrify.logger import LOGGER_FUNCTION 

10from integrify.schemas import APIResponse, PayloadBaseModel, _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. 

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 metodunun (`POST`, `GET`, və s.) 

55 """ 

56 self.urls[route_name] = {'url': url, 'verb': verb} 

57 

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 

62 

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

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

65 

66 Args: 

67 handler_class: Default handler class-ı 

68 """ 

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

70 

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

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

73 

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

79 

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

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

82 

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 

93 

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) 

100 

101 func = self.request_executor.request_function 

102 return lambda *args, **kwds: func(url, verb, handler, *args, **kwds) 

103 

104 

105class APIPayloadHandler: 

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

107 

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 

124 

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

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

127 

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

134 

135 return url 

136 

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 ) 

145 

146 @cached_property 

147 def headers(self) -> dict: 

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

149 return {} 

150 

151 @cached_property 

152 def req_args(self) -> dict: 

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

154 return {} 

155 

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. 

160 

161 Misal üçün: Bax [`EPointClientClass`][integrify.epoint.client.EPointClientClass] 

162 """ 

163 

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 ) 

176 

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

178 assert not args 

179 

180 return kwds 

181 

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. 

185 

186 Misal üçün: Bax [`EPointClientClass`][integrify.epoint.client.EPointClientClass] 

187 

188 Args: 

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

190 """ 

191 return data # pragma: no cover 

192 

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

197 

198 1. Pre-processing 

199 2. Payload hazırlama 

200 3. Post-processing 

201 """ 

202 

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) 

206 

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 

216 

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

218 

219 

220class APIExecutor: 

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

222 

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) 

235 

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

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

238 

239 if sync: 

240 self.client = httpx.Client(timeout=10) 

241 else: 

242 self.client = httpx.AsyncClient(timeout=10) 

243 

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 

261 

262 return self.async_req # pragma: no cover 

263 

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 

273 

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) 

280 

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

282 headers = handler.headers 

283 full_url = handler.set_urlparams(url) 

284 

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 ) 

292 

293 response = self.client.request( 

294 verb, 

295 full_url, 

296 data=data, 

297 headers=headers, 

298 **handler.req_args, 

299 ) 

300 

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 ) 

309 

310 return handler.handle_response(response) 

311 

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 

321 

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) 

328 

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

330 headers = handler.headers 

331 full_url = handler.set_urlparams(url) 

332 

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 ) 

340 

341 response = await self.client.request( 

342 verb, 

343 full_url, 

344 data=data, 

345 headers=headers, 

346 **handler.req_args, 

347 ) 

348 

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 ) 

357 

358 return handler.handle_response(response)