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.
- universal_http_api_client/__init__.py +35 -0
- universal_http_api_client/adapters.py +314 -0
- universal_http_api_client/client.py +572 -0
- universal_http_api_client/exceptions.py +209 -0
- universal_http_api_client/models.py +200 -0
- universal_http_api_client/py.typed +0 -0
- universal_http_api_client/utils.py +40 -0
- yeonjae_universal_http_api_client-1.0.1.dist-info/METADATA +77 -0
- yeonjae_universal_http_api_client-1.0.1.dist-info/RECORD +11 -0
- yeonjae_universal_http_api_client-1.0.1.dist-info/WHEEL +5 -0
- yeonjae_universal_http_api_client-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
"""
|
2
|
+
Universal HTTP API Client - 범용 HTTP API 클라이언트
|
3
|
+
|
4
|
+
이 모듈은 GitHub, GitLab 등 다양한 플랫폼의 API를 호출하는 범용 클라이언트입니다.
|
5
|
+
재사용성을 고려하여 설계되었으며, 인증, 재시도, 캐싱 등의 기능을 제공합니다.
|
6
|
+
"""
|
7
|
+
|
8
|
+
__version__ = "1.0.0"
|
9
|
+
|
10
|
+
from .client import HTTPAPIClient, AsyncHTTPAPIClient
|
11
|
+
from .adapters import PlatformAPIAdapter, GitHubAdapter, GitLabAdapter
|
12
|
+
from .models import APIRequest, APIResponse, PlatformConfig, Platform, HTTPMethod
|
13
|
+
from .exceptions import APIError, RateLimitError, AuthenticationError, NetworkError, TimeoutError
|
14
|
+
from .utils import ModuleIOLogger, setup_logging
|
15
|
+
|
16
|
+
__all__ = [
|
17
|
+
"HTTPAPIClient",
|
18
|
+
"AsyncHTTPAPIClient",
|
19
|
+
"PlatformAPIAdapter",
|
20
|
+
"GitHubAdapter",
|
21
|
+
"GitLabAdapter",
|
22
|
+
"APIRequest",
|
23
|
+
"APIResponse",
|
24
|
+
"PlatformConfig",
|
25
|
+
"Platform",
|
26
|
+
"HTTPMethod",
|
27
|
+
"APIError",
|
28
|
+
"RateLimitError",
|
29
|
+
"AuthenticationError",
|
30
|
+
"NetworkError",
|
31
|
+
"TimeoutError",
|
32
|
+
"ModuleIOLogger",
|
33
|
+
"setup_logging",
|
34
|
+
"__version__",
|
35
|
+
]
|
@@ -0,0 +1,314 @@
|
|
1
|
+
"""
|
2
|
+
플랫폼별 API 어댑터 구현
|
3
|
+
"""
|
4
|
+
|
5
|
+
import hashlib
|
6
|
+
import time
|
7
|
+
from abc import ABC, abstractmethod
|
8
|
+
from typing import Dict, Any, Optional, Callable
|
9
|
+
from datetime import datetime
|
10
|
+
|
11
|
+
from .models import Platform, PlatformConfig, APIRequest, APIResponse, RateLimitInfo, PLATFORM_CONFIGS
|
12
|
+
from .exceptions import RateLimitError, PlatformNotSupportedError
|
13
|
+
|
14
|
+
|
15
|
+
class PlatformAPIAdapter(ABC):
|
16
|
+
"""플랫폼 API 어댑터 기본 클래스"""
|
17
|
+
|
18
|
+
def __init__(self, config: PlatformConfig, auth_token: str):
|
19
|
+
self.config = config
|
20
|
+
self.auth_token = auth_token
|
21
|
+
|
22
|
+
@abstractmethod
|
23
|
+
def build_url(self, endpoint: str) -> str:
|
24
|
+
"""API URL 구성"""
|
25
|
+
pass
|
26
|
+
|
27
|
+
@abstractmethod
|
28
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
29
|
+
"""인증 헤더 반환"""
|
30
|
+
pass
|
31
|
+
|
32
|
+
@abstractmethod
|
33
|
+
def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
|
34
|
+
"""Rate limit 정보 파싱"""
|
35
|
+
pass
|
36
|
+
|
37
|
+
@abstractmethod
|
38
|
+
def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
|
39
|
+
"""응답 데이터 파싱"""
|
40
|
+
pass
|
41
|
+
|
42
|
+
def get_default_headers(self) -> Dict[str, str]:
|
43
|
+
"""기본 헤더 반환"""
|
44
|
+
headers = self.config.default_headers.copy()
|
45
|
+
headers.update(self.get_auth_headers())
|
46
|
+
return headers
|
47
|
+
|
48
|
+
def get_cache_key(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> str:
|
49
|
+
"""캐시 키 생성"""
|
50
|
+
params_str = ""
|
51
|
+
if params:
|
52
|
+
# 파라미터를 정렬하여 일관된 해시 생성
|
53
|
+
sorted_params = sorted(params.items())
|
54
|
+
params_str = str(sorted_params)
|
55
|
+
|
56
|
+
cache_data = f"{self.config.name}:{endpoint}:{params_str}"
|
57
|
+
return hashlib.md5(cache_data.encode()).hexdigest()
|
58
|
+
|
59
|
+
|
60
|
+
class GitHubAdapter(PlatformAPIAdapter):
|
61
|
+
"""GitHub API 어댑터"""
|
62
|
+
|
63
|
+
def build_url(self, endpoint: str) -> str:
|
64
|
+
"""GitHub API URL 구성"""
|
65
|
+
# endpoint가 이미 전체 URL이면 그대로 반환
|
66
|
+
if endpoint.startswith('http'):
|
67
|
+
return endpoint
|
68
|
+
|
69
|
+
# 앞에 슬래시가 없으면 추가
|
70
|
+
if not endpoint.startswith('/'):
|
71
|
+
endpoint = f"/{endpoint}"
|
72
|
+
|
73
|
+
return f"{self.config.base_url}{endpoint}"
|
74
|
+
|
75
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
76
|
+
"""GitHub 인증 헤더"""
|
77
|
+
return self.config.get_auth_header(self.auth_token)
|
78
|
+
|
79
|
+
def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
|
80
|
+
"""GitHub Rate limit 정보 파싱"""
|
81
|
+
try:
|
82
|
+
remaining = int(headers.get('X-RateLimit-Remaining', 0))
|
83
|
+
limit = int(headers.get('X-RateLimit-Limit', 5000))
|
84
|
+
reset_timestamp = int(headers.get('X-RateLimit-Reset', 0))
|
85
|
+
reset_time = datetime.fromtimestamp(reset_timestamp)
|
86
|
+
|
87
|
+
return RateLimitInfo(
|
88
|
+
remaining=remaining,
|
89
|
+
limit=limit,
|
90
|
+
reset_time=reset_time
|
91
|
+
)
|
92
|
+
except (ValueError, TypeError):
|
93
|
+
return None
|
94
|
+
|
95
|
+
def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
|
96
|
+
"""GitHub 응답 데이터 파싱"""
|
97
|
+
if operation == "get_commit":
|
98
|
+
return self._parse_commit_response(response_data)
|
99
|
+
elif operation == "get_diff":
|
100
|
+
return self._parse_diff_response(response_data)
|
101
|
+
elif operation == "get_repository":
|
102
|
+
return self._parse_repository_response(response_data)
|
103
|
+
else:
|
104
|
+
return response_data
|
105
|
+
|
106
|
+
def _parse_commit_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
107
|
+
"""커밋 응답 파싱"""
|
108
|
+
return {
|
109
|
+
"sha": data.get("sha"),
|
110
|
+
"message": data.get("commit", {}).get("message"),
|
111
|
+
"author": data.get("commit", {}).get("author", {}),
|
112
|
+
"committer": data.get("commit", {}).get("committer", {}),
|
113
|
+
"stats": data.get("stats", {}),
|
114
|
+
"files": data.get("files", []),
|
115
|
+
"parents": data.get("parents", []),
|
116
|
+
"url": data.get("html_url")
|
117
|
+
}
|
118
|
+
|
119
|
+
def _parse_diff_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
120
|
+
"""Diff 응답 파싱"""
|
121
|
+
return {
|
122
|
+
"files": data.get("files", []),
|
123
|
+
"stats": data.get("stats", {}),
|
124
|
+
"total_additions": sum(f.get("additions", 0) for f in data.get("files", [])),
|
125
|
+
"total_deletions": sum(f.get("deletions", 0) for f in data.get("files", [])),
|
126
|
+
"total_changes": sum(f.get("changes", 0) for f in data.get("files", []))
|
127
|
+
}
|
128
|
+
|
129
|
+
def _parse_repository_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
130
|
+
"""저장소 응답 파싱"""
|
131
|
+
return {
|
132
|
+
"id": data.get("id"),
|
133
|
+
"name": data.get("name"),
|
134
|
+
"full_name": data.get("full_name"),
|
135
|
+
"owner": data.get("owner", {}),
|
136
|
+
"private": data.get("private"),
|
137
|
+
"description": data.get("description"),
|
138
|
+
"language": data.get("language"),
|
139
|
+
"size": data.get("size"),
|
140
|
+
"stars": data.get("stargazers_count"),
|
141
|
+
"forks": data.get("forks_count"),
|
142
|
+
"created_at": data.get("created_at"),
|
143
|
+
"updated_at": data.get("updated_at"),
|
144
|
+
"url": data.get("html_url")
|
145
|
+
}
|
146
|
+
|
147
|
+
|
148
|
+
class GitLabAdapter(PlatformAPIAdapter):
|
149
|
+
"""GitLab API 어댑터"""
|
150
|
+
|
151
|
+
def build_url(self, endpoint: str) -> str:
|
152
|
+
"""GitLab API URL 구성"""
|
153
|
+
if endpoint.startswith('http'):
|
154
|
+
return endpoint
|
155
|
+
|
156
|
+
if not endpoint.startswith('/'):
|
157
|
+
endpoint = f"/{endpoint}"
|
158
|
+
|
159
|
+
return f"{self.config.base_url}{endpoint}"
|
160
|
+
|
161
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
162
|
+
"""GitLab 인증 헤더"""
|
163
|
+
return self.config.get_auth_header(self.auth_token)
|
164
|
+
|
165
|
+
def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
|
166
|
+
"""GitLab Rate limit 정보 파싱"""
|
167
|
+
try:
|
168
|
+
remaining = int(headers.get('RateLimit-Remaining', 0))
|
169
|
+
limit = int(headers.get('RateLimit-Limit', 300))
|
170
|
+
reset_timestamp = int(headers.get('RateLimit-Reset', 0))
|
171
|
+
reset_time = datetime.fromtimestamp(reset_timestamp)
|
172
|
+
|
173
|
+
return RateLimitInfo(
|
174
|
+
remaining=remaining,
|
175
|
+
limit=limit,
|
176
|
+
reset_time=reset_time
|
177
|
+
)
|
178
|
+
except (ValueError, TypeError):
|
179
|
+
return None
|
180
|
+
|
181
|
+
def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
|
182
|
+
"""GitLab 응답 데이터 파싱"""
|
183
|
+
if operation == "get_commit":
|
184
|
+
return self._parse_commit_response(response_data)
|
185
|
+
elif operation == "get_diff":
|
186
|
+
return self._parse_diff_response(response_data)
|
187
|
+
elif operation == "get_project":
|
188
|
+
return self._parse_project_response(response_data)
|
189
|
+
else:
|
190
|
+
return response_data
|
191
|
+
|
192
|
+
def _parse_commit_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
193
|
+
"""커밋 응답 파싱"""
|
194
|
+
return {
|
195
|
+
"sha": data.get("id"),
|
196
|
+
"message": data.get("message"),
|
197
|
+
"author": {
|
198
|
+
"name": data.get("author_name"),
|
199
|
+
"email": data.get("author_email"),
|
200
|
+
"date": data.get("authored_date")
|
201
|
+
},
|
202
|
+
"committer": {
|
203
|
+
"name": data.get("committer_name"),
|
204
|
+
"email": data.get("committer_email"),
|
205
|
+
"date": data.get("committed_date")
|
206
|
+
},
|
207
|
+
"stats": data.get("stats", {}),
|
208
|
+
"parent_ids": data.get("parent_ids", []),
|
209
|
+
"url": data.get("web_url")
|
210
|
+
}
|
211
|
+
|
212
|
+
def _parse_diff_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
213
|
+
"""Diff 응답 파싱"""
|
214
|
+
# GitLab의 diff 응답은 파일별로 분리되어 있음
|
215
|
+
files = []
|
216
|
+
total_additions = 0
|
217
|
+
total_deletions = 0
|
218
|
+
|
219
|
+
if isinstance(data, list):
|
220
|
+
for file_diff in data:
|
221
|
+
files.append(file_diff)
|
222
|
+
# GitLab diff에서 통계 정보 추출
|
223
|
+
diff_text = file_diff.get("diff", "")
|
224
|
+
additions = diff_text.count("\n+") if diff_text else 0
|
225
|
+
deletions = diff_text.count("\n-") if diff_text else 0
|
226
|
+
total_additions += additions
|
227
|
+
total_deletions += deletions
|
228
|
+
|
229
|
+
return {
|
230
|
+
"files": files,
|
231
|
+
"total_additions": total_additions,
|
232
|
+
"total_deletions": total_deletions,
|
233
|
+
"total_changes": total_additions + total_deletions
|
234
|
+
}
|
235
|
+
|
236
|
+
def _parse_project_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
237
|
+
"""프로젝트 응답 파싱"""
|
238
|
+
return {
|
239
|
+
"id": data.get("id"),
|
240
|
+
"name": data.get("name"),
|
241
|
+
"full_name": data.get("path_with_namespace"),
|
242
|
+
"owner": data.get("owner", {}),
|
243
|
+
"private": data.get("visibility") == "private",
|
244
|
+
"description": data.get("description"),
|
245
|
+
"language": None, # GitLab API에서는 별도 조회 필요
|
246
|
+
"size": data.get("repository_size"),
|
247
|
+
"stars": data.get("star_count"),
|
248
|
+
"forks": data.get("forks_count"),
|
249
|
+
"created_at": data.get("created_at"),
|
250
|
+
"updated_at": data.get("last_activity_at"),
|
251
|
+
"url": data.get("web_url")
|
252
|
+
}
|
253
|
+
|
254
|
+
|
255
|
+
class BitbucketAdapter(PlatformAPIAdapter):
|
256
|
+
"""Bitbucket API 어댑터"""
|
257
|
+
|
258
|
+
def build_url(self, endpoint: str) -> str:
|
259
|
+
"""Bitbucket API URL 구성"""
|
260
|
+
if endpoint.startswith('http'):
|
261
|
+
return endpoint
|
262
|
+
|
263
|
+
if not endpoint.startswith('/'):
|
264
|
+
endpoint = f"/{endpoint}"
|
265
|
+
|
266
|
+
return f"{self.config.base_url}{endpoint}"
|
267
|
+
|
268
|
+
def get_auth_headers(self) -> Dict[str, str]:
|
269
|
+
"""Bitbucket 인증 헤더"""
|
270
|
+
return self.config.get_auth_header(self.auth_token)
|
271
|
+
|
272
|
+
def parse_rate_limit(self, headers: Dict[str, str]) -> Optional[RateLimitInfo]:
|
273
|
+
"""Bitbucket Rate limit 정보 파싱"""
|
274
|
+
# Bitbucket은 특별한 rate limit 헤더가 없음
|
275
|
+
return None
|
276
|
+
|
277
|
+
def parse_response(self, response_data: Dict[str, Any], operation: str) -> Dict[str, Any]:
|
278
|
+
"""Bitbucket 응답 데이터 파싱"""
|
279
|
+
# Bitbucket API 응답 구조에 맞게 파싱
|
280
|
+
return response_data
|
281
|
+
|
282
|
+
|
283
|
+
class AdapterFactory:
|
284
|
+
"""어댑터 팩토리"""
|
285
|
+
|
286
|
+
_adapters = {
|
287
|
+
Platform.GITHUB: GitHubAdapter,
|
288
|
+
Platform.GITLAB: GitLabAdapter,
|
289
|
+
Platform.BITBUCKET: BitbucketAdapter
|
290
|
+
}
|
291
|
+
|
292
|
+
@classmethod
|
293
|
+
def create_adapter(cls, platform: Platform, auth_token: str) -> PlatformAPIAdapter:
|
294
|
+
"""플랫폼에 맞는 어댑터 생성"""
|
295
|
+
if platform not in cls._adapters:
|
296
|
+
supported = [p.value for p in cls._adapters.keys()]
|
297
|
+
raise PlatformNotSupportedError(platform.value, supported)
|
298
|
+
|
299
|
+
config = PLATFORM_CONFIGS.get(platform)
|
300
|
+
if not config:
|
301
|
+
raise PlatformNotSupportedError(platform.value)
|
302
|
+
|
303
|
+
adapter_class = cls._adapters[platform]
|
304
|
+
return adapter_class(config, auth_token)
|
305
|
+
|
306
|
+
@classmethod
|
307
|
+
def register_adapter(cls, platform: Platform, adapter_class: type):
|
308
|
+
"""새로운 어댑터 등록"""
|
309
|
+
cls._adapters[platform] = adapter_class
|
310
|
+
|
311
|
+
@classmethod
|
312
|
+
def get_supported_platforms(cls) -> list:
|
313
|
+
"""지원하는 플랫폼 목록 반환"""
|
314
|
+
return [platform.value for platform in cls._adapters.keys()]
|