yeonjae-universal-http-api-client 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ """
2
+ HTTPAPIClient 핵심 클라이언트 구현
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from typing import Dict, Any, Optional, Callable, Union
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ import aiohttp
13
+ import requests
14
+ from requests.adapters import HTTPAdapter
15
+ from urllib3.util.retry import Retry
16
+
17
+ from .models import (
18
+ Platform, HTTPMethod, APIRequest, APIResponse,
19
+ RateLimitInfo, CacheKey, CachedResponse
20
+ )
21
+ from .adapters import AdapterFactory, PlatformAPIAdapter
22
+ from .exceptions import (
23
+ APIError, RateLimitError, AuthenticationError, NetworkError,
24
+ TimeoutError, handle_http_error
25
+ )
26
+ from .utils import ModuleIOLogger
27
+
28
+
29
+ class RateLimiter:
30
+ """Rate Limiter 구현"""
31
+
32
+ def __init__(self):
33
+ self._limits: Dict[str, RateLimitInfo] = {}
34
+ self._requests: Dict[str, list] = {}
35
+
36
+ def can_make_request(self, platform: str, current_limit: Optional[RateLimitInfo] = None) -> bool:
37
+ """요청 가능 여부 확인"""
38
+ if current_limit and current_limit.is_exhausted:
39
+ return False
40
+
41
+ # 플랫폼별 요청 이력 확인
42
+ now = time.time()
43
+ platform_requests = self._requests.get(platform, [])
44
+
45
+ # 1분 전 요청들 제거
46
+ recent_requests = [req_time for req_time in platform_requests if now - req_time < 60]
47
+ self._requests[platform] = recent_requests
48
+
49
+ # 분당 요청 제한 확인 (예: 60 요청/분)
50
+ return len(recent_requests) < 60
51
+
52
+ def record_request(self, platform: str):
53
+ """요청 기록"""
54
+ now = time.time()
55
+ if platform not in self._requests:
56
+ self._requests[platform] = []
57
+ self._requests[platform].append(now)
58
+
59
+ def update_limits(self, platform: str, limit_info: RateLimitInfo):
60
+ """Rate limit 정보 업데이트"""
61
+ self._limits[platform] = limit_info
62
+
63
+ def get_wait_time(self, platform: str) -> float:
64
+ """대기 시간 계산"""
65
+ limit_info = self._limits.get(platform)
66
+ if limit_info and limit_info.is_exhausted:
67
+ return (limit_info.reset_time - datetime.now()).total_seconds()
68
+ return 0
69
+
70
+
71
+ class SimpleCache:
72
+ """간단한 인메모리 캐시"""
73
+
74
+ def __init__(self, max_size: int = 1000):
75
+ self._cache: Dict[str, CachedResponse] = {}
76
+ self._max_size = max_size
77
+
78
+ def get(self, key: str) -> Optional[APIResponse]:
79
+ """캐시에서 응답 조회"""
80
+ cached = self._cache.get(key)
81
+ if cached and not cached.is_expired:
82
+ response = cached.response
83
+ response.cached = True
84
+ return response
85
+ elif cached:
86
+ # 만료된 캐시 제거
87
+ del self._cache[key]
88
+ return None
89
+
90
+ def set(self, key: str, response: APIResponse, ttl: int = 300):
91
+ """캐시에 응답 저장"""
92
+ if len(self._cache) >= self._max_size:
93
+ # 가장 오래된 항목 제거
94
+ oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k].cached_at)
95
+ del self._cache[oldest_key]
96
+
97
+ self._cache[key] = CachedResponse(
98
+ response=response,
99
+ cached_at=datetime.now(timezone.utc),
100
+ ttl=ttl
101
+ )
102
+
103
+ def clear(self):
104
+ """캐시 초기화"""
105
+ self._cache.clear()
106
+
107
+
108
+ class HTTPAPIClient:
109
+ """범용 HTTP API 클라이언트"""
110
+
111
+ def __init__(
112
+ self,
113
+ platform: Platform,
114
+ auth_token: str,
115
+ enable_cache: bool = True,
116
+ enable_rate_limiting: bool = True,
117
+ max_retries: int = 3,
118
+ timeout: int = 30,
119
+ session: Optional[requests.Session] = None
120
+ ):
121
+ self.platform = platform
122
+ self.auth_token = auth_token
123
+ self.enable_cache = enable_cache
124
+ self.enable_rate_limiting = enable_rate_limiting
125
+ self.max_retries = max_retries
126
+ self.timeout = timeout
127
+
128
+ # 어댑터 초기화
129
+ self.adapter = AdapterFactory.create_adapter(platform, auth_token)
130
+
131
+ # Rate Limiter 초기화
132
+ self.rate_limiter = RateLimiter() if enable_rate_limiting else None
133
+
134
+ # 캐시 초기화
135
+ self.cache = SimpleCache() if enable_cache else None
136
+
137
+ # HTTP 세션 설정
138
+ self.session = session or self._create_session()
139
+
140
+ # 로거 설정
141
+ self.logger = logging.getLogger(f"HTTPAPIClient.{platform.value}")
142
+
143
+ # 입출력 로거 설정
144
+ self.io_logger = ModuleIOLogger("HTTPAPIClient")
145
+
146
+ def _create_session(self) -> requests.Session:
147
+ """HTTP 세션 생성"""
148
+ session = requests.Session()
149
+
150
+ # 재시도 정책 설정
151
+ retry_strategy = Retry(
152
+ total=self.max_retries,
153
+ status_forcelist=[429, 500, 502, 503, 504],
154
+ backoff_factor=1,
155
+ respect_retry_after_header=True
156
+ )
157
+
158
+ adapter = HTTPAdapter(max_retries=retry_strategy)
159
+ session.mount("http://", adapter)
160
+ session.mount("https://", adapter)
161
+
162
+ return session
163
+
164
+ def request(
165
+ self,
166
+ method: HTTPMethod,
167
+ endpoint: str,
168
+ params: Optional[Dict[str, Any]] = None,
169
+ data: Optional[Dict[str, Any]] = None,
170
+ headers: Optional[Dict[str, str]] = None,
171
+ timeout: Optional[int] = None,
172
+ cache_ttl: int = 300,
173
+ operation: str = "generic"
174
+ ) -> APIResponse:
175
+ """HTTP 요청 실행"""
176
+ start_time = time.time()
177
+
178
+ # API 요청 객체 생성
179
+ api_request = APIRequest(
180
+ platform=self.platform,
181
+ method=method,
182
+ endpoint=endpoint,
183
+ headers=headers,
184
+ params=params,
185
+ data=data,
186
+ timeout=timeout or self.timeout
187
+ )
188
+
189
+ try:
190
+ # 캐시 확인 (GET 요청만)
191
+ if method == HTTPMethod.GET and self.cache:
192
+ cache_key = self._get_cache_key(endpoint, params)
193
+ cached_response = self.cache.get(cache_key)
194
+ if cached_response:
195
+ self.logger.debug(f"Cache hit for {endpoint}")
196
+ return cached_response
197
+
198
+ # Rate limit 확인
199
+ if self.rate_limiter and not self.rate_limiter.can_make_request(self.platform.value):
200
+ wait_time = self.rate_limiter.get_wait_time(self.platform.value)
201
+ if wait_time > 0:
202
+ raise RateLimitError(
203
+ f"Rate limit exceeded, wait {wait_time:.1f}s",
204
+ retry_after=int(wait_time),
205
+ platform=self.platform.value
206
+ )
207
+
208
+ # 실제 HTTP 요청 실행
209
+ response = self._execute_request(api_request)
210
+
211
+ # Rate limit 정보 업데이트
212
+ if self.rate_limiter:
213
+ rate_limit_info = self.adapter.parse_rate_limit(response.headers)
214
+ if rate_limit_info:
215
+ self.rate_limiter.update_limits(self.platform.value, rate_limit_info)
216
+
217
+ # Rate limit 임계값 경고
218
+ if rate_limit_info.is_near_limit():
219
+ self.logger.warning(
220
+ f"Rate limit warning: {rate_limit_info.remaining} requests remaining"
221
+ )
222
+
223
+ self.rate_limiter.record_request(self.platform.value)
224
+
225
+ # 응답 파싱
226
+ parsed_data = self.adapter.parse_response(response.data, operation)
227
+ response.data = parsed_data
228
+
229
+ # 응답 시간 기록
230
+ response.response_time = time.time() - start_time
231
+
232
+ # 캐시 저장 (GET 요청 성공시만)
233
+ if (method == HTTPMethod.GET and self.cache and
234
+ response.success and 200 <= response.status_code < 300):
235
+ cache_key = self._get_cache_key(endpoint, params)
236
+ self.cache.set(cache_key, response, cache_ttl)
237
+
238
+ self.logger.info(
239
+ f"{method.value} {endpoint} - {response.status_code} "
240
+ f"({response.response_time:.2f}s)"
241
+ )
242
+
243
+ return response
244
+
245
+ except Exception as e:
246
+ error_time = time.time() - start_time
247
+ self.logger.error(
248
+ f"{method.value} {endpoint} failed ({error_time:.2f}s): {str(e)}"
249
+ )
250
+ raise
251
+
252
+ def _execute_request(self, api_request: APIRequest) -> APIResponse:
253
+ """실제 HTTP 요청 실행"""
254
+ url = self.adapter.build_url(api_request.endpoint)
255
+
256
+ # 헤더 준비
257
+ headers = self.adapter.get_default_headers()
258
+ if api_request.headers:
259
+ headers.update(api_request.headers)
260
+
261
+ try:
262
+ # requests 라이브러리로 요청 실행
263
+ response = self.session.request(
264
+ method=api_request.method.value,
265
+ url=url,
266
+ params=api_request.params,
267
+ json=api_request.data if api_request.data else None,
268
+ headers=headers,
269
+ timeout=api_request.timeout
270
+ )
271
+
272
+ # 응답 데이터 파싱
273
+ try:
274
+ response_data = response.json() if response.content else {}
275
+ except json.JSONDecodeError:
276
+ response_data = {"raw_content": response.text}
277
+
278
+ # 성공 응답
279
+ if response.ok:
280
+ return APIResponse.success_response(
281
+ status_code=response.status_code,
282
+ data=response_data,
283
+ headers=dict(response.headers)
284
+ )
285
+
286
+ # 에러 응답
287
+ error_message = self._extract_error_message(response_data, response.text)
288
+ raise handle_http_error(response.status_code, error_message, self.platform.value)
289
+
290
+ except requests.exceptions.Timeout:
291
+ raise TimeoutError(
292
+ f"Request timeout after {api_request.timeout}s",
293
+ timeout=api_request.timeout,
294
+ platform=self.platform.value
295
+ )
296
+ except requests.exceptions.ConnectionError as e:
297
+ raise NetworkError(
298
+ f"Network connection failed: {str(e)}",
299
+ platform=self.platform.value
300
+ )
301
+ except requests.exceptions.RequestException as e:
302
+ raise APIError(
303
+ f"Request failed: {str(e)}",
304
+ platform=self.platform.value
305
+ )
306
+
307
+ def _extract_error_message(self, response_data: Dict[str, Any], raw_text: str) -> str:
308
+ """에러 메시지 추출"""
309
+ # 플랫폼별 에러 메시지 형식에 맞게 추출
310
+ if isinstance(response_data, dict):
311
+ # GitHub 스타일
312
+ if "message" in response_data:
313
+ return response_data["message"]
314
+
315
+ # GitLab 스타일
316
+ if "error" in response_data:
317
+ error = response_data["error"]
318
+ if isinstance(error, str):
319
+ return error
320
+ elif isinstance(error, dict) and "message" in error:
321
+ return error["message"]
322
+
323
+ # 일반적인 형식들
324
+ for key in ["error_description", "detail", "details"]:
325
+ if key in response_data:
326
+ return str(response_data[key])
327
+
328
+ return raw_text[:200] if raw_text else "Unknown error"
329
+
330
+ def _get_cache_key(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> str:
331
+ """캐시 키 생성"""
332
+ return self.adapter.get_cache_key(endpoint, params)
333
+
334
+ # 편의 메서드들
335
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
336
+ """GET 요청"""
337
+ return self.request(HTTPMethod.GET, endpoint, params=params, **kwargs)
338
+
339
+ def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
340
+ """POST 요청"""
341
+ return self.request(HTTPMethod.POST, endpoint, data=data, **kwargs)
342
+
343
+ def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
344
+ """PUT 요청"""
345
+ return self.request(HTTPMethod.PUT, endpoint, data=data, **kwargs)
346
+
347
+ def delete(self, endpoint: str, **kwargs) -> APIResponse:
348
+ """DELETE 요청"""
349
+ return self.request(HTTPMethod.DELETE, endpoint, **kwargs)
350
+
351
+ def patch(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
352
+ """PATCH 요청"""
353
+ return self.request(HTTPMethod.PATCH, endpoint, data=data, **kwargs)
354
+
355
+ # 플랫폼별 특화 메서드들
356
+ def get_commit(self, repo_name: str, commit_sha: str) -> APIResponse:
357
+ """커밋 정보 조회"""
358
+ # 입력 로깅
359
+ self.io_logger.log_input(
360
+ "get_commit",
361
+ data={"repo_name": repo_name, "commit_sha": commit_sha},
362
+ metadata={"platform": self.platform.value}
363
+ )
364
+
365
+ try:
366
+ if self.platform == Platform.GITHUB:
367
+ endpoint = f"/repos/{repo_name}/commits/{commit_sha}"
368
+ elif self.platform == Platform.GITLAB:
369
+ # GitLab에서는 프로젝트 ID 필요
370
+ endpoint = f"/projects/{repo_name.replace('/', '%2F')}/commits/{commit_sha}"
371
+ else:
372
+ raise APIError(f"get_commit not implemented for {self.platform.value}")
373
+
374
+ response = self.get(endpoint, operation="get_commit")
375
+
376
+ # 출력 로깅
377
+ self.io_logger.log_output(
378
+ "get_commit",
379
+ data=response.data,
380
+ metadata={
381
+ "platform": self.platform.value,
382
+ "status_code": response.status_code,
383
+ "success": response.success,
384
+ "commit_sha": commit_sha,
385
+ "files_changed": len(response.data.get("files", [])) if response.data else 0
386
+ }
387
+ )
388
+
389
+ return response
390
+
391
+ except Exception as e:
392
+ # 오류 로깅
393
+ self.io_logger.log_error(
394
+ "get_commit",
395
+ e,
396
+ metadata={
397
+ "platform": self.platform.value,
398
+ "repo_name": repo_name,
399
+ "commit_sha": commit_sha
400
+ }
401
+ )
402
+ raise
403
+
404
+ def get_repository(self, repo_name: str) -> APIResponse:
405
+ """저장소 정보 조회"""
406
+ if self.platform == Platform.GITHUB:
407
+ endpoint = f"/repos/{repo_name}"
408
+ operation = "get_repository"
409
+ elif self.platform == Platform.GITLAB:
410
+ endpoint = f"/projects/{repo_name.replace('/', '%2F')}"
411
+ operation = "get_project"
412
+ else:
413
+ raise APIError(f"get_repository not implemented for {self.platform.value}")
414
+
415
+ return self.get(endpoint, operation=operation)
416
+
417
+ def get_diff(self, repo_name: str, commit_sha: str) -> APIResponse:
418
+ """커밋 diff 조회"""
419
+ if self.platform == Platform.GITHUB:
420
+ endpoint = f"/repos/{repo_name}/commits/{commit_sha}"
421
+ elif self.platform == Platform.GITLAB:
422
+ endpoint = f"/projects/{repo_name.replace('/', '%2F')}/commits/{commit_sha}/diff"
423
+ else:
424
+ raise APIError(f"get_diff not implemented for {self.platform.value}")
425
+
426
+ return self.get(endpoint, operation="get_diff")
427
+
428
+ def close(self):
429
+ """클라이언트 정리"""
430
+ if self.session:
431
+ self.session.close()
432
+ if self.cache:
433
+ self.cache.clear()
434
+
435
+
436
+ class AsyncHTTPAPIClient:
437
+ """비동기 HTTP API 클라이언트"""
438
+
439
+ def __init__(
440
+ self,
441
+ platform: Platform,
442
+ auth_token: str,
443
+ enable_cache: bool = True,
444
+ enable_rate_limiting: bool = True,
445
+ max_retries: int = 3,
446
+ timeout: int = 30
447
+ ):
448
+ self.platform = platform
449
+ self.auth_token = auth_token
450
+ self.enable_cache = enable_cache
451
+ self.enable_rate_limiting = enable_rate_limiting
452
+ self.max_retries = max_retries
453
+ self.timeout = timeout
454
+
455
+ # 어댑터 초기화
456
+ self.adapter = AdapterFactory.create_adapter(platform, auth_token)
457
+
458
+ # Rate Limiter 초기화
459
+ self.rate_limiter = RateLimiter() if enable_rate_limiting else None
460
+
461
+ # 캐시 초기화
462
+ self.cache = SimpleCache() if enable_cache else None
463
+
464
+ # 로거 설정
465
+ self.logger = logging.getLogger(f"AsyncHTTPAPIClient.{platform.value}")
466
+
467
+ async def request(
468
+ self,
469
+ method: HTTPMethod,
470
+ endpoint: str,
471
+ params: Optional[Dict[str, Any]] = None,
472
+ data: Optional[Dict[str, Any]] = None,
473
+ headers: Optional[Dict[str, str]] = None,
474
+ timeout: Optional[int] = None,
475
+ cache_ttl: int = 300,
476
+ operation: str = "generic"
477
+ ) -> APIResponse:
478
+ """비동기 HTTP 요청 실행"""
479
+ start_time = time.time()
480
+
481
+ # 캐시 확인 (GET 요청만)
482
+ if method == HTTPMethod.GET and self.cache:
483
+ cache_key = self.adapter.get_cache_key(endpoint, params)
484
+ cached_response = self.cache.get(cache_key)
485
+ if cached_response:
486
+ self.logger.debug(f"Cache hit for {endpoint}")
487
+ return cached_response
488
+
489
+ url = self.adapter.build_url(endpoint)
490
+ request_headers = self.adapter.get_default_headers()
491
+ if headers:
492
+ request_headers.update(headers)
493
+
494
+ timeout_config = aiohttp.ClientTimeout(total=timeout or self.timeout)
495
+
496
+ try:
497
+ async with aiohttp.ClientSession(timeout=timeout_config) as session:
498
+ async with session.request(
499
+ method=method.value,
500
+ url=url,
501
+ params=params,
502
+ json=data,
503
+ headers=request_headers
504
+ ) as response:
505
+
506
+ # 응답 데이터 파싱
507
+ try:
508
+ response_data = await response.json()
509
+ except:
510
+ response_text = await response.text()
511
+ response_data = {"raw_content": response_text}
512
+
513
+ response_time = time.time() - start_time
514
+
515
+ if response.ok:
516
+ # 응답 파싱
517
+ parsed_data = self.adapter.parse_response(response_data, operation)
518
+
519
+ api_response = APIResponse.success_response(
520
+ status_code=response.status,
521
+ data=parsed_data,
522
+ headers=dict(response.headers),
523
+ response_time=response_time
524
+ )
525
+
526
+ # 캐시 저장
527
+ if (method == HTTPMethod.GET and self.cache and
528
+ 200 <= response.status < 300):
529
+ cache_key = self.adapter.get_cache_key(endpoint, params)
530
+ self.cache.set(cache_key, api_response, cache_ttl)
531
+
532
+ return api_response
533
+ else:
534
+ error_message = self._extract_error_message(response_data, "")
535
+ raise handle_http_error(response.status, error_message, self.platform.value)
536
+
537
+ except asyncio.TimeoutError:
538
+ raise TimeoutError(
539
+ f"Request timeout after {timeout or self.timeout}s",
540
+ timeout=timeout or self.timeout,
541
+ platform=self.platform.value
542
+ )
543
+ except aiohttp.ClientError as e:
544
+ raise NetworkError(
545
+ f"Network error: {str(e)}",
546
+ platform=self.platform.value
547
+ )
548
+
549
+ def _extract_error_message(self, response_data: Dict[str, Any], raw_text: str) -> str:
550
+ """에러 메시지 추출 (동기 버전과 동일)"""
551
+ if isinstance(response_data, dict):
552
+ if "message" in response_data:
553
+ return response_data["message"]
554
+ if "error" in response_data:
555
+ error = response_data["error"]
556
+ if isinstance(error, str):
557
+ return error
558
+ elif isinstance(error, dict) and "message" in error:
559
+ return error["message"]
560
+ for key in ["error_description", "detail", "details"]:
561
+ if key in response_data:
562
+ return str(response_data[key])
563
+ return raw_text[:200] if raw_text else "Unknown error"
564
+
565
+ # 편의 메서드들 (동기 버전과 동일한 인터페이스)
566
+ async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
567
+ """비동기 GET 요청"""
568
+ return await self.request(HTTPMethod.GET, endpoint, params=params, **kwargs)
569
+
570
+ async def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> APIResponse:
571
+ """비동기 POST 요청"""
572
+ return await self.request(HTTPMethod.POST, endpoint, data=data, **kwargs)