Spaces:
Running
Running
Delete api_executor.py
Browse files- api_executor.py +0 -426
api_executor.py
DELETED
@@ -1,426 +0,0 @@
|
|
1 |
-
"""
|
2 |
-
Flare – API Executor (v2.0 · session-aware token management)
|
3 |
-
"""
|
4 |
-
|
5 |
-
from __future__ import annotations
|
6 |
-
import json, re, time, requests
|
7 |
-
from typing import Any, Dict, Optional, Union
|
8 |
-
from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer
|
9 |
-
from config.config_provider import ConfigProvider, APIConfig
|
10 |
-
from session import Session
|
11 |
-
import os
|
12 |
-
|
13 |
-
MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB
|
14 |
-
DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30"))
|
15 |
-
|
16 |
-
_placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}")
|
17 |
-
|
18 |
-
def _get_variable_value(session: Session, var_path: str) -> Any:
|
19 |
-
cfg = ConfigProvider.get()
|
20 |
-
|
21 |
-
"""Get variable value with proper type from session"""
|
22 |
-
if var_path.startswith("variables."):
|
23 |
-
var_name = var_path.split(".", 1)[1]
|
24 |
-
return session.variables.get(var_name)
|
25 |
-
elif var_path.startswith("auth_tokens."):
|
26 |
-
parts = var_path.split(".")
|
27 |
-
if len(parts) >= 3:
|
28 |
-
token_api = parts[1]
|
29 |
-
token_field = parts[2]
|
30 |
-
token_data = session._auth_tokens.get(token_api, {})
|
31 |
-
return token_data.get(token_field)
|
32 |
-
elif var_path.startswith("config."):
|
33 |
-
attr_name = var_path.split(".", 1)[1]
|
34 |
-
return getattr(cfg.global_config, attr_name, None)
|
35 |
-
return None
|
36 |
-
|
37 |
-
def _render_value(value: Any) -> Union[str, int, float, bool, None]:
|
38 |
-
"""Convert value to appropriate JSON type"""
|
39 |
-
if value is None:
|
40 |
-
return None
|
41 |
-
elif isinstance(value, bool):
|
42 |
-
return value
|
43 |
-
elif isinstance(value, (int, float)):
|
44 |
-
return value
|
45 |
-
elif isinstance(value, str):
|
46 |
-
# Check if it's a number string
|
47 |
-
if value.isdigit():
|
48 |
-
return int(value)
|
49 |
-
try:
|
50 |
-
return float(value)
|
51 |
-
except ValueError:
|
52 |
-
pass
|
53 |
-
# Check if it's a boolean string
|
54 |
-
if value.lower() in ('true', 'false'):
|
55 |
-
return value.lower() == 'true'
|
56 |
-
# Return as string
|
57 |
-
return value
|
58 |
-
else:
|
59 |
-
return str(value)
|
60 |
-
|
61 |
-
def _render_json(obj: Any, session: Session, api_name: str) -> Any:
|
62 |
-
"""Render JSON preserving types"""
|
63 |
-
if isinstance(obj, str):
|
64 |
-
# Check if entire string is a template
|
65 |
-
template_match = _placeholder.fullmatch(obj.strip())
|
66 |
-
if template_match:
|
67 |
-
# This is a pure template like {{variables.pnr}}
|
68 |
-
var_path = template_match.group(1).strip()
|
69 |
-
value = _get_variable_value(session, var_path)
|
70 |
-
return _render_value(value)
|
71 |
-
else:
|
72 |
-
# String with embedded templates or regular string
|
73 |
-
def replacer(match):
|
74 |
-
var_path = match.group(1).strip()
|
75 |
-
value = _get_variable_value(session, var_path)
|
76 |
-
return str(value) if value is not None else ""
|
77 |
-
|
78 |
-
return _placeholder.sub(replacer, obj)
|
79 |
-
|
80 |
-
elif isinstance(obj, dict):
|
81 |
-
return {k: _render_json(v, session, api_name) for k, v in obj.items()}
|
82 |
-
|
83 |
-
elif isinstance(obj, list):
|
84 |
-
return [_render_json(v, session, api_name) for v in obj]
|
85 |
-
|
86 |
-
else:
|
87 |
-
# Return as-is for numbers, booleans, None
|
88 |
-
return obj
|
89 |
-
|
90 |
-
def _render(obj: Any, session: Session, api_name: str) -> Any:
|
91 |
-
"""Render template with session variables and tokens"""
|
92 |
-
# For headers and other string-only contexts
|
93 |
-
if isinstance(obj, str):
|
94 |
-
def replacer(match):
|
95 |
-
var_path = match.group(1).strip()
|
96 |
-
value = _get_variable_value(session, var_path)
|
97 |
-
return str(value) if value is not None else ""
|
98 |
-
|
99 |
-
return _placeholder.sub(replacer, obj)
|
100 |
-
|
101 |
-
elif isinstance(obj, dict):
|
102 |
-
return {k: _render(v, session, api_name) for k, v in obj.items()}
|
103 |
-
|
104 |
-
elif isinstance(obj, list):
|
105 |
-
return [_render(v, session, api_name) for v in obj]
|
106 |
-
|
107 |
-
return obj
|
108 |
-
|
109 |
-
def _fetch_token(api: APIConfig, session: Session) -> None:
|
110 |
-
"""Fetch new auth token"""
|
111 |
-
if not api.auth or not api.auth.enabled:
|
112 |
-
return
|
113 |
-
|
114 |
-
log_info(f"🔑 Fetching token for {api.name}")
|
115 |
-
|
116 |
-
try:
|
117 |
-
# Use _render_json for body to preserve types
|
118 |
-
body = _render_json(api.auth.token_request_body, session, api.name)
|
119 |
-
headers = {"Content-Type": "application/json"}
|
120 |
-
|
121 |
-
response = requests.post(
|
122 |
-
str(api.auth.token_endpoint),
|
123 |
-
json=body,
|
124 |
-
headers=headers,
|
125 |
-
timeout=api.timeout_seconds
|
126 |
-
)
|
127 |
-
response.raise_for_status()
|
128 |
-
|
129 |
-
json_data = response.json()
|
130 |
-
|
131 |
-
# Extract token using path
|
132 |
-
token = json_data
|
133 |
-
for path_part in api.auth.response_token_path.split("."):
|
134 |
-
token = token.get(path_part)
|
135 |
-
if token is None:
|
136 |
-
raise ValueError(f"Token path {api.auth.response_token_path} not found in response")
|
137 |
-
|
138 |
-
# Store in session
|
139 |
-
session._auth_tokens[api.name] = {
|
140 |
-
"token": token,
|
141 |
-
"expiry": time.time() + 3500, # ~1 hour
|
142 |
-
"refresh_token": json_data.get("refresh_token")
|
143 |
-
}
|
144 |
-
|
145 |
-
log_info(f"✅ Token obtained for {api.name}")
|
146 |
-
|
147 |
-
except Exception as e:
|
148 |
-
log_error(f"❌ Token fetch failed for {api.name}", e)
|
149 |
-
raise
|
150 |
-
|
151 |
-
def _refresh_token(api: APIConfig, session: Session) -> bool:
|
152 |
-
"""Refresh existing token"""
|
153 |
-
if not api.auth or not api.auth.token_refresh_endpoint:
|
154 |
-
return False
|
155 |
-
|
156 |
-
token_info = session._auth_tokens.get(api.name, {})
|
157 |
-
if not token_info.get("refresh_token"):
|
158 |
-
return False
|
159 |
-
|
160 |
-
log_info(f"🔄 Refreshing token for {api.name}")
|
161 |
-
|
162 |
-
try:
|
163 |
-
body = _render_json(api.auth.token_refresh_body or {}, session, api.name)
|
164 |
-
body["refresh_token"] = token_info["refresh_token"]
|
165 |
-
|
166 |
-
response = requests.post(
|
167 |
-
str(api.auth.token_refresh_endpoint),
|
168 |
-
json=body,
|
169 |
-
timeout=api.timeout_seconds
|
170 |
-
)
|
171 |
-
response.raise_for_status()
|
172 |
-
|
173 |
-
json_data = response.json()
|
174 |
-
|
175 |
-
# Extract new token
|
176 |
-
token = json_data
|
177 |
-
for path_part in api.auth.response_token_path.split("."):
|
178 |
-
token = token.get(path_part)
|
179 |
-
if token is None:
|
180 |
-
raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response")
|
181 |
-
|
182 |
-
# Update session
|
183 |
-
session._auth_tokens[api.name] = {
|
184 |
-
"token": token,
|
185 |
-
"expiry": time.time() + 3500,
|
186 |
-
"refresh_token": json_data.get("refresh_token", token_info["refresh_token"])
|
187 |
-
}
|
188 |
-
|
189 |
-
log_info(f"✅ Token refreshed for {api.name}")
|
190 |
-
return True
|
191 |
-
|
192 |
-
except Exception as e:
|
193 |
-
log_error(f"❌ Token refresh failed for {api.name}", e)
|
194 |
-
return False
|
195 |
-
|
196 |
-
def _ensure_token(api: APIConfig, session: Session) -> None:
|
197 |
-
"""Ensure valid token exists for API"""
|
198 |
-
if not api.auth or not api.auth.enabled:
|
199 |
-
return
|
200 |
-
|
201 |
-
token_info = session._auth_tokens.get(api.name)
|
202 |
-
|
203 |
-
# No token yet
|
204 |
-
if not token_info:
|
205 |
-
_fetch_token(api, session)
|
206 |
-
return
|
207 |
-
|
208 |
-
# Token still valid
|
209 |
-
if token_info.get("expiry", 0) > time.time():
|
210 |
-
return
|
211 |
-
|
212 |
-
# Try refresh first
|
213 |
-
if _refresh_token(api, session):
|
214 |
-
return
|
215 |
-
|
216 |
-
# Refresh failed, get new token
|
217 |
-
_fetch_token(api, session)
|
218 |
-
|
219 |
-
def call_api(api: APIConfig, session: Session) -> requests.Response:
|
220 |
-
"""Execute API call with automatic token management and better error handling"""
|
221 |
-
|
222 |
-
# Ensure valid token
|
223 |
-
_ensure_token(api, session)
|
224 |
-
|
225 |
-
# Prepare request
|
226 |
-
headers = _render(api.headers, session, api.name)
|
227 |
-
body = _render_json(api.body_template, session, api.name)
|
228 |
-
|
229 |
-
# Get timeout with fallback
|
230 |
-
timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT
|
231 |
-
|
232 |
-
# Handle proxy
|
233 |
-
proxies = None
|
234 |
-
if api.proxy:
|
235 |
-
if isinstance(api.proxy, str):
|
236 |
-
proxies = {"http": api.proxy, "https": api.proxy}
|
237 |
-
elif hasattr(api.proxy, "enabled") and api.proxy.enabled:
|
238 |
-
proxy_url = str(api.proxy.url)
|
239 |
-
proxies = {"http": proxy_url, "https": proxy_url}
|
240 |
-
|
241 |
-
# Prepare request parameters
|
242 |
-
request_params = {
|
243 |
-
"method": api.method,
|
244 |
-
"url": str(api.url),
|
245 |
-
"headers": headers,
|
246 |
-
"timeout": timeout, # Use configured timeout
|
247 |
-
"stream": True # Enable streaming for large responses
|
248 |
-
}
|
249 |
-
|
250 |
-
# Add body based on method
|
251 |
-
if api.method in ("POST", "PUT", "PATCH"):
|
252 |
-
request_params["json"] = body
|
253 |
-
elif api.method == "GET" and body:
|
254 |
-
request_params["params"] = body
|
255 |
-
|
256 |
-
if proxies:
|
257 |
-
request_params["proxies"] = proxies
|
258 |
-
|
259 |
-
# Execute with retry
|
260 |
-
retry_count = api.retry.retry_count if api.retry else 0
|
261 |
-
last_error = None
|
262 |
-
response = None
|
263 |
-
|
264 |
-
for attempt in range(retry_count + 1):
|
265 |
-
try:
|
266 |
-
# Use LogTimer for performance tracking
|
267 |
-
with LogTimer(f"API call {api.name}", attempt=attempt + 1):
|
268 |
-
log_info(
|
269 |
-
f"🌐 API call starting",
|
270 |
-
api=api.name,
|
271 |
-
method=api.method,
|
272 |
-
url=api.url,
|
273 |
-
attempt=f"{attempt + 1}/{retry_count + 1}",
|
274 |
-
timeout=timeout
|
275 |
-
)
|
276 |
-
|
277 |
-
if body:
|
278 |
-
log_debug(f"📋 Request body", body=json.dumps(body, ensure_ascii=False)[:500])
|
279 |
-
|
280 |
-
# Make request with streaming
|
281 |
-
response = requests.request(**request_params)
|
282 |
-
|
283 |
-
# Check response size from headers
|
284 |
-
content_length = response.headers.get('content-length')
|
285 |
-
if content_length and int(content_length) > MAX_RESPONSE_SIZE:
|
286 |
-
response.close()
|
287 |
-
raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})")
|
288 |
-
|
289 |
-
# Handle 401 Unauthorized
|
290 |
-
if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count:
|
291 |
-
log_warning(f"🔒 Got 401, refreshing token", api=api.name)
|
292 |
-
_fetch_token(api, session)
|
293 |
-
headers = _render(api.headers, session, api.name)
|
294 |
-
request_params["headers"] = headers
|
295 |
-
response.close()
|
296 |
-
continue
|
297 |
-
|
298 |
-
# Read response with size limit
|
299 |
-
content_size = 0
|
300 |
-
chunks = []
|
301 |
-
|
302 |
-
for chunk in response.iter_content(chunk_size=8192):
|
303 |
-
chunks.append(chunk)
|
304 |
-
content_size += len(chunk)
|
305 |
-
|
306 |
-
if content_size > MAX_RESPONSE_SIZE:
|
307 |
-
response.close()
|
308 |
-
raise ValueError(f"Response exceeded size limit: {content_size} bytes")
|
309 |
-
|
310 |
-
# Reconstruct response content
|
311 |
-
response._content = b''.join(chunks)
|
312 |
-
response._content_consumed = True
|
313 |
-
|
314 |
-
# Check status
|
315 |
-
response.raise_for_status()
|
316 |
-
|
317 |
-
log_info(
|
318 |
-
f"✅ API call successful",
|
319 |
-
api=api.name,
|
320 |
-
status_code=response.status_code,
|
321 |
-
response_size=content_size,
|
322 |
-
duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}"
|
323 |
-
)
|
324 |
-
|
325 |
-
# Mevcut response mapping işlemi korunacak
|
326 |
-
if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings:
|
327 |
-
try:
|
328 |
-
if response.status_code != 204 and response.content:
|
329 |
-
response_json = response.json()
|
330 |
-
|
331 |
-
for mapping in api.response_mappings:
|
332 |
-
var_name = mapping.get('variable_name')
|
333 |
-
var_type = mapping.get('type', 'str')
|
334 |
-
json_path = mapping.get('json_path')
|
335 |
-
|
336 |
-
if not all([var_name, json_path]):
|
337 |
-
continue
|
338 |
-
|
339 |
-
# JSON path'ten değeri al
|
340 |
-
value = response_json
|
341 |
-
for path_part in json_path.split('.'):
|
342 |
-
if isinstance(value, dict):
|
343 |
-
value = value.get(path_part)
|
344 |
-
if value is None:
|
345 |
-
break
|
346 |
-
|
347 |
-
if value is not None:
|
348 |
-
# Type conversion
|
349 |
-
if var_type == 'int':
|
350 |
-
value = int(value)
|
351 |
-
elif var_type == 'float':
|
352 |
-
value = float(value)
|
353 |
-
elif var_type == 'bool':
|
354 |
-
value = bool(value)
|
355 |
-
elif var_type == 'date':
|
356 |
-
value = str(value)
|
357 |
-
else: # str
|
358 |
-
value = str(value)
|
359 |
-
|
360 |
-
# Session'a kaydet
|
361 |
-
session.variables[var_name] = value
|
362 |
-
log_info(f"📝 Mapped response", variable=var_name, value=value)
|
363 |
-
|
364 |
-
except Exception as e:
|
365 |
-
log_error("⚠️ Response mapping error", error=str(e))
|
366 |
-
|
367 |
-
return response
|
368 |
-
|
369 |
-
except requests.exceptions.Timeout as e:
|
370 |
-
last_error = e
|
371 |
-
log_warning(
|
372 |
-
f"⏱️ API timeout",
|
373 |
-
api=api.name,
|
374 |
-
attempt=attempt + 1,
|
375 |
-
timeout=timeout
|
376 |
-
)
|
377 |
-
|
378 |
-
except requests.exceptions.RequestException as e:
|
379 |
-
last_error = e
|
380 |
-
log_error(
|
381 |
-
f"❌ API request error",
|
382 |
-
api=api.name,
|
383 |
-
error=str(e),
|
384 |
-
attempt=attempt + 1
|
385 |
-
)
|
386 |
-
|
387 |
-
except ValueError as e: # Size limit exceeded
|
388 |
-
log_error(
|
389 |
-
f"❌ Response size error",
|
390 |
-
api=api.name,
|
391 |
-
error=str(e)
|
392 |
-
)
|
393 |
-
raise # Don't retry for size errors
|
394 |
-
|
395 |
-
except Exception as e:
|
396 |
-
last_error = e
|
397 |
-
log_error(
|
398 |
-
f"❌ Unexpected API error",
|
399 |
-
api=api.name,
|
400 |
-
error=str(e),
|
401 |
-
attempt=attempt + 1
|
402 |
-
)
|
403 |
-
|
404 |
-
# Retry backoff
|
405 |
-
if attempt < retry_count:
|
406 |
-
backoff = api.retry.backoff_seconds if api.retry else 2
|
407 |
-
if api.retry and api.retry.strategy == "exponential":
|
408 |
-
backoff = backoff * (2 ** attempt)
|
409 |
-
log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2)
|
410 |
-
time.sleep(backoff)
|
411 |
-
|
412 |
-
# All retries failed
|
413 |
-
error_msg = f"API call failed after {retry_count + 1} attempts"
|
414 |
-
log_error(error_msg, api=api.name, last_error=str(last_error))
|
415 |
-
|
416 |
-
if last_error:
|
417 |
-
raise last_error
|
418 |
-
raise requests.exceptions.RequestException(error_msg)
|
419 |
-
|
420 |
-
def format_size(size_bytes: int) -> str:
|
421 |
-
"""Format bytes to human readable format"""
|
422 |
-
for unit in ['B', 'KB', 'MB', 'GB']:
|
423 |
-
if size_bytes < 1024.0:
|
424 |
-
return f"{size_bytes:.2f} {unit}"
|
425 |
-
size_bytes /= 1024.0
|
426 |
-
return f"{size_bytes:.2f} TB"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|