socialseed-e2e 0.1.0__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.
- socialseed_e2e/__init__.py +51 -0
- socialseed_e2e/__version__.py +21 -0
- socialseed_e2e/cli.py +611 -0
- socialseed_e2e/core/__init__.py +35 -0
- socialseed_e2e/core/base_page.py +839 -0
- socialseed_e2e/core/check_deps.py +43 -0
- socialseed_e2e/core/config.py +119 -0
- socialseed_e2e/core/config_loader.py +604 -0
- socialseed_e2e/core/headers.py +20 -0
- socialseed_e2e/core/interfaces.py +22 -0
- socialseed_e2e/core/loaders.py +51 -0
- socialseed_e2e/core/models.py +24 -0
- socialseed_e2e/core/test_orchestrator.py +84 -0
- socialseed_e2e/services/__init__.py +9 -0
- socialseed_e2e/templates/__init__.py +32 -0
- socialseed_e2e/templates/config.py.template +20 -0
- socialseed_e2e/templates/data_schema.py.template +116 -0
- socialseed_e2e/templates/e2e.conf.template +20 -0
- socialseed_e2e/templates/service_page.py.template +83 -0
- socialseed_e2e/templates/test_module.py.template +46 -0
- socialseed_e2e/utils/__init__.py +44 -0
- socialseed_e2e/utils/template_engine.py +246 -0
- socialseed_e2e/utils/validators.py +588 -0
- socialseed_e2e-0.1.0.dist-info/METADATA +333 -0
- socialseed_e2e-0.1.0.dist-info/RECORD +29 -0
- socialseed_e2e-0.1.0.dist-info/WHEEL +5 -0
- socialseed_e2e-0.1.0.dist-info/entry_points.txt +3 -0
- socialseed_e2e-0.1.0.dist-info/licenses/LICENSE +21 -0
- socialseed_e2e-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""Enhanced BasePage with logging, retries, and rate limiting.
|
|
2
|
+
|
|
3
|
+
This module provides an enhanced BasePage class for API testing with
|
|
4
|
+
production-ready features including structured logging, automatic retries,
|
|
5
|
+
rate limiting, and comprehensive request/response logging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
|
14
|
+
|
|
15
|
+
from playwright.sync_api import APIRequestContext, APIResponse, Playwright
|
|
16
|
+
|
|
17
|
+
from socialseed_e2e.core.headers import DEFAULT_BROWSER_HEADERS, DEFAULT_JSON_HEADERS
|
|
18
|
+
from socialseed_e2e.core.models import ServiceConfig
|
|
19
|
+
|
|
20
|
+
# Configure logger
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RetryConfig:
|
|
26
|
+
"""Configuration for automatic retry mechanism.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
30
|
+
backoff_factor: Exponential backoff multiplier (default: 1.0)
|
|
31
|
+
max_backoff: Maximum backoff time in seconds (default: 60)
|
|
32
|
+
retry_on: List of HTTP status codes to retry on (default: [502, 503, 504])
|
|
33
|
+
retry_exceptions: List of exception types to retry on
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
max_retries: int = 3
|
|
37
|
+
backoff_factor: float = 1.0
|
|
38
|
+
max_backoff: float = 60.0
|
|
39
|
+
retry_on: List[int] = None
|
|
40
|
+
retry_exceptions: List[Type[Exception]] = None
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if self.retry_on is None:
|
|
44
|
+
self.retry_on = [502, 503, 504, 429] # Include 429 (rate limit)
|
|
45
|
+
if self.retry_exceptions is None:
|
|
46
|
+
self.retry_exceptions = [Exception]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RateLimitConfig:
|
|
51
|
+
"""Configuration for rate limiting.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
enabled: Whether rate limiting is enabled (default: False)
|
|
55
|
+
requests_per_second: Maximum requests per second (default: 10)
|
|
56
|
+
requests_per_minute: Maximum requests per minute (default: 600)
|
|
57
|
+
burst_size: Allow burst of requests (default: 5)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
enabled: bool = False
|
|
61
|
+
requests_per_second: float = 10.0
|
|
62
|
+
requests_per_minute: float = 600.0
|
|
63
|
+
burst_size: int = 5
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RequestLog:
|
|
68
|
+
"""Log entry for a single request.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
method: HTTP method
|
|
72
|
+
url: Full request URL
|
|
73
|
+
headers: Request headers (may be filtered)
|
|
74
|
+
body: Request body
|
|
75
|
+
timestamp: When the request was made
|
|
76
|
+
duration_ms: Request duration in milliseconds
|
|
77
|
+
status: Response status code
|
|
78
|
+
response_headers: Response headers
|
|
79
|
+
response_body: Response body (truncated if too large)
|
|
80
|
+
error: Error message if request failed
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
method: str
|
|
84
|
+
url: str
|
|
85
|
+
headers: Dict[str, str]
|
|
86
|
+
body: Optional[str]
|
|
87
|
+
timestamp: float
|
|
88
|
+
duration_ms: float = 0.0
|
|
89
|
+
status: Optional[int] = None
|
|
90
|
+
response_headers: Optional[Dict[str, str]] = None
|
|
91
|
+
response_body: Optional[str] = None
|
|
92
|
+
error: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BasePageError(Exception):
|
|
96
|
+
"""Enhanced exception with request context."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
message: str,
|
|
101
|
+
url: Optional[str] = None,
|
|
102
|
+
method: Optional[str] = None,
|
|
103
|
+
status: Optional[int] = None,
|
|
104
|
+
response_text: Optional[str] = None,
|
|
105
|
+
request_log: Optional[RequestLog] = None,
|
|
106
|
+
):
|
|
107
|
+
super().__init__(message)
|
|
108
|
+
self.url = url
|
|
109
|
+
self.method = method
|
|
110
|
+
self.status = status
|
|
111
|
+
self.response_text = response_text
|
|
112
|
+
self.request_log = request_log
|
|
113
|
+
|
|
114
|
+
def __str__(self) -> str:
|
|
115
|
+
parts = [super().__str__()]
|
|
116
|
+
if self.method and self.url:
|
|
117
|
+
parts.append(f"Request: {self.method} {self.url}")
|
|
118
|
+
if self.status:
|
|
119
|
+
parts.append(f"Status: {self.status}")
|
|
120
|
+
if self.response_text:
|
|
121
|
+
preview = self.response_text[:200]
|
|
122
|
+
if len(self.response_text) > 200:
|
|
123
|
+
preview += "..."
|
|
124
|
+
parts.append(f"Response: {preview}")
|
|
125
|
+
return "\n ".join(parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class BasePage:
|
|
129
|
+
"""Enhanced base class for API testing with logging, retries, and rate limiting.
|
|
130
|
+
|
|
131
|
+
This class extends the basic API testing capabilities with production-ready
|
|
132
|
+
features including:
|
|
133
|
+
|
|
134
|
+
- Structured logging of all requests and responses
|
|
135
|
+
- Automatic retry mechanism with exponential backoff
|
|
136
|
+
- Rate limiting to avoid overwhelming APIs
|
|
137
|
+
- Request timing and performance metrics
|
|
138
|
+
- Enhanced error messages with full context
|
|
139
|
+
- Helper methods for common assertions
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> page = BasePage("https://api.example.com")
|
|
143
|
+
>>> page.setup()
|
|
144
|
+
>>>
|
|
145
|
+
>>> # Enable retries for transient failures
|
|
146
|
+
>>> page.retry_config = RetryConfig(max_retries=3)
|
|
147
|
+
>>>
|
|
148
|
+
>>> # Enable rate limiting
|
|
149
|
+
>>> page.rate_limit_config = RateLimitConfig(
|
|
150
|
+
... enabled=True,
|
|
151
|
+
... requests_per_second=5
|
|
152
|
+
... )
|
|
153
|
+
>>>
|
|
154
|
+
>>> # Make request with automatic retry and logging
|
|
155
|
+
>>> response = page.get("/users/123")
|
|
156
|
+
>>>
|
|
157
|
+
>>> # Use helper methods for assertions
|
|
158
|
+
>>> page.assert_status(response, 200)
|
|
159
|
+
>>> user = page.assert_json(response)
|
|
160
|
+
>>>
|
|
161
|
+
>>> page.teardown()
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
base_url: The base URL for the API
|
|
165
|
+
default_headers: Headers applied to all requests
|
|
166
|
+
retry_config: Configuration for automatic retries
|
|
167
|
+
rate_limit_config: Configuration for rate limiting
|
|
168
|
+
enable_request_logging: Whether to log all requests
|
|
169
|
+
max_log_body_size: Maximum size for logged request/response bodies
|
|
170
|
+
request_history: List of RequestLog entries for recent requests
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
base_url: str,
|
|
176
|
+
playwright: Optional[Playwright] = None,
|
|
177
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
178
|
+
retry_config: Optional[RetryConfig] = None,
|
|
179
|
+
rate_limit_config: Optional[RateLimitConfig] = None,
|
|
180
|
+
enable_request_logging: bool = True,
|
|
181
|
+
max_log_body_size: int = 10000,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Initialize the BasePage.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
base_url: The base URL for the API (e.g., "https://api.example.com")
|
|
187
|
+
playwright: Optional Playwright instance (created if not provided)
|
|
188
|
+
default_headers: Headers to include in all requests
|
|
189
|
+
retry_config: Configuration for automatic retries (default: no retries)
|
|
190
|
+
rate_limit_config: Configuration for rate limiting (default: disabled)
|
|
191
|
+
enable_request_logging: Whether to log requests and responses
|
|
192
|
+
max_log_body_size: Maximum size for logged bodies (truncated if larger)
|
|
193
|
+
"""
|
|
194
|
+
self.base_url: str = base_url.rstrip("/")
|
|
195
|
+
self.playwright_manager: Optional[Any] = None
|
|
196
|
+
self.playwright: Optional[Playwright] = None
|
|
197
|
+
self.default_headers = (
|
|
198
|
+
default_headers
|
|
199
|
+
if default_headers is not None
|
|
200
|
+
else {**DEFAULT_JSON_HEADERS, **DEFAULT_BROWSER_HEADERS}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Initialize Playwright
|
|
204
|
+
if playwright:
|
|
205
|
+
self.playwright = playwright
|
|
206
|
+
else:
|
|
207
|
+
self.playwright_manager = __import__("playwright").sync_api.sync_playwright()
|
|
208
|
+
self.playwright = self.playwright_manager.__enter__()
|
|
209
|
+
|
|
210
|
+
self.api_context: Optional[APIRequestContext] = None
|
|
211
|
+
|
|
212
|
+
# Configuration
|
|
213
|
+
self.retry_config = retry_config or RetryConfig(max_retries=0) # Disabled by default
|
|
214
|
+
self.rate_limit_config = rate_limit_config or RateLimitConfig(enabled=False)
|
|
215
|
+
self.enable_request_logging = enable_request_logging
|
|
216
|
+
self.max_log_body_size = max_log_body_size
|
|
217
|
+
|
|
218
|
+
# Rate limiting state
|
|
219
|
+
self._request_times: List[float] = []
|
|
220
|
+
self._last_request_time: float = 0.0
|
|
221
|
+
|
|
222
|
+
# Request history (last 100 requests)
|
|
223
|
+
self.request_history: List[RequestLog] = []
|
|
224
|
+
self._max_history_size = 100
|
|
225
|
+
|
|
226
|
+
logger.info(f"BasePage initialized for {self.base_url}")
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_config(
|
|
230
|
+
cls, config: ServiceConfig, playwright: Optional[Playwright] = None, **kwargs
|
|
231
|
+
) -> "BasePage":
|
|
232
|
+
"""Factory method to create a BasePage from a ServiceConfig object.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
config: Service configuration object
|
|
236
|
+
playwright: Optional Playwright instance
|
|
237
|
+
**kwargs: Additional arguments passed to BasePage constructor
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Configured BasePage instance
|
|
241
|
+
"""
|
|
242
|
+
return cls(
|
|
243
|
+
base_url=config.base_url,
|
|
244
|
+
playwright=playwright,
|
|
245
|
+
default_headers=config.default_headers or None,
|
|
246
|
+
**kwargs,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def setup(self) -> None:
|
|
250
|
+
"""Initialize the API context.
|
|
251
|
+
|
|
252
|
+
This method creates the Playwright APIRequestContext. It is called
|
|
253
|
+
automatically before making requests if not already set up.
|
|
254
|
+
"""
|
|
255
|
+
if not self.api_context:
|
|
256
|
+
self.api_context = self.playwright.request.new_context()
|
|
257
|
+
logger.debug("API context initialized")
|
|
258
|
+
|
|
259
|
+
def teardown(self) -> None:
|
|
260
|
+
"""Clean up the API context and resources.
|
|
261
|
+
|
|
262
|
+
Always call this method when done to release resources properly.
|
|
263
|
+
"""
|
|
264
|
+
if self.api_context:
|
|
265
|
+
self.api_context.dispose()
|
|
266
|
+
self.api_context = None
|
|
267
|
+
logger.debug("API context disposed")
|
|
268
|
+
|
|
269
|
+
if self.playwright_manager:
|
|
270
|
+
self.playwright_manager.__exit__(None, None, None)
|
|
271
|
+
self.playwright_manager = None
|
|
272
|
+
self.playwright = None
|
|
273
|
+
logger.debug("Playwright manager cleaned up")
|
|
274
|
+
|
|
275
|
+
logger.info("BasePage teardown complete")
|
|
276
|
+
|
|
277
|
+
def _ensure_setup(self) -> None:
|
|
278
|
+
"""Ensure API context is set up before making requests."""
|
|
279
|
+
if not self.api_context:
|
|
280
|
+
self.setup()
|
|
281
|
+
|
|
282
|
+
def _prepare_headers(self, headers: Optional[Dict[str, str]]) -> Dict[str, str]:
|
|
283
|
+
"""Combine default headers with request-specific headers.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
headers: Request-specific headers to merge with defaults
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Merged headers dictionary
|
|
290
|
+
"""
|
|
291
|
+
request_headers = self.default_headers.copy()
|
|
292
|
+
if headers:
|
|
293
|
+
request_headers.update(headers)
|
|
294
|
+
return request_headers
|
|
295
|
+
|
|
296
|
+
def _apply_rate_limit(self) -> None:
|
|
297
|
+
"""Apply rate limiting before making a request.
|
|
298
|
+
|
|
299
|
+
This method enforces the configured rate limits by sleeping
|
|
300
|
+
if necessary to maintain the desired request rate.
|
|
301
|
+
"""
|
|
302
|
+
if not self.rate_limit_config.enabled:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
now = time.time()
|
|
306
|
+
|
|
307
|
+
# Clean up old request times (older than 1 minute)
|
|
308
|
+
cutoff = now - 60.0
|
|
309
|
+
self._request_times = [t for t in self._request_times if t > cutoff]
|
|
310
|
+
|
|
311
|
+
# Check per-minute limit
|
|
312
|
+
if len(self._request_times) >= self.rate_limit_config.requests_per_minute:
|
|
313
|
+
sleep_time = 60.0 - (now - self._request_times[0])
|
|
314
|
+
if sleep_time > 0:
|
|
315
|
+
logger.warning(f"Rate limit (per minute) reached, sleeping for {sleep_time:.2f}s")
|
|
316
|
+
time.sleep(sleep_time)
|
|
317
|
+
|
|
318
|
+
# Check per-second limit with burst allowance
|
|
319
|
+
recent_requests = len([t for t in self._request_times if t > now - 1.0])
|
|
320
|
+
if (
|
|
321
|
+
recent_requests
|
|
322
|
+
>= self.rate_limit_config.requests_per_second + self.rate_limit_config.burst_size
|
|
323
|
+
):
|
|
324
|
+
sleep_time = 1.0 / self.rate_limit_config.requests_per_second
|
|
325
|
+
logger.warning(f"Rate limit (per second) reached, sleeping for {sleep_time:.2f}s")
|
|
326
|
+
time.sleep(sleep_time)
|
|
327
|
+
|
|
328
|
+
# Update request tracking
|
|
329
|
+
self._request_times.append(now)
|
|
330
|
+
self._last_request_time = now
|
|
331
|
+
|
|
332
|
+
def _calculate_backoff(self, attempt: int) -> float:
|
|
333
|
+
"""Calculate exponential backoff time.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
attempt: Current retry attempt number (0-indexed)
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Sleep time in seconds
|
|
340
|
+
"""
|
|
341
|
+
backoff = self.retry_config.backoff_factor * (2**attempt)
|
|
342
|
+
return min(backoff, self.retry_config.max_backoff)
|
|
343
|
+
|
|
344
|
+
def _should_retry(
|
|
345
|
+
self, response: Optional[APIResponse], exception: Optional[Exception]
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""Determine if a request should be retried.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
response: The API response (None if exception occurred)
|
|
351
|
+
exception: The exception that occurred (None if successful)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
True if the request should be retried
|
|
355
|
+
"""
|
|
356
|
+
if self.retry_config.max_retries <= 0:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
# Check if exception type is in retry list
|
|
360
|
+
if exception:
|
|
361
|
+
return any(
|
|
362
|
+
isinstance(exception, exc_type) for exc_type in self.retry_config.retry_exceptions
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Check if status code is in retry list
|
|
366
|
+
if response and response.status in self.retry_config.retry_on:
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def _log_request(self, log_entry: RequestLog) -> None:
|
|
372
|
+
"""Add a request to the history and log it.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
log_entry: The request log entry to add
|
|
376
|
+
"""
|
|
377
|
+
self.request_history.append(log_entry)
|
|
378
|
+
|
|
379
|
+
# Trim history if too large
|
|
380
|
+
if len(self.request_history) > self._max_history_size:
|
|
381
|
+
self.request_history.pop(0)
|
|
382
|
+
|
|
383
|
+
if self.enable_request_logging:
|
|
384
|
+
if log_entry.error:
|
|
385
|
+
logger.error(
|
|
386
|
+
f"{log_entry.method} {log_entry.url} - ERROR: {log_entry.error} "
|
|
387
|
+
f"({log_entry.duration_ms:.0f}ms)"
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
logger.info(
|
|
391
|
+
f"{log_entry.method} {log_entry.url} - {log_entry.status} "
|
|
392
|
+
f"({log_entry.duration_ms:.0f}ms)"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _truncate_body(self, body: Optional[str]) -> Optional[str]:
|
|
396
|
+
"""Truncate body for logging if too large.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
body: The body string to truncate
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Truncated body or None
|
|
403
|
+
"""
|
|
404
|
+
if not body:
|
|
405
|
+
return None
|
|
406
|
+
if len(body) > self.max_log_body_size:
|
|
407
|
+
return (
|
|
408
|
+
body[: self.max_log_body_size] + f"\n... [truncated, total size: {len(body)} bytes]"
|
|
409
|
+
)
|
|
410
|
+
return body
|
|
411
|
+
|
|
412
|
+
def _make_request(self, method: str, endpoint: str, **kwargs) -> APIResponse:
|
|
413
|
+
"""Make an HTTP request with retry logic and logging.
|
|
414
|
+
|
|
415
|
+
This is the core method that handles all requests with:
|
|
416
|
+
- Rate limiting
|
|
417
|
+
- Automatic retries
|
|
418
|
+
- Request/response logging
|
|
419
|
+
- Timing information
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
423
|
+
endpoint: API endpoint (e.g., "/users/123")
|
|
424
|
+
**kwargs: Additional arguments for the Playwright request
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
APIResponse object
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
BasePageError: If the request fails after all retries
|
|
431
|
+
"""
|
|
432
|
+
self._ensure_setup()
|
|
433
|
+
self._apply_rate_limit()
|
|
434
|
+
|
|
435
|
+
full_url = f"{self.base_url}{endpoint}"
|
|
436
|
+
last_exception: Optional[Exception] = None
|
|
437
|
+
last_response: Optional[APIResponse] = None
|
|
438
|
+
|
|
439
|
+
# Prepare request log
|
|
440
|
+
request_log = RequestLog(
|
|
441
|
+
method=method,
|
|
442
|
+
url=full_url,
|
|
443
|
+
headers=self._prepare_headers(kwargs.get("headers")),
|
|
444
|
+
body=self._truncate_body(str(kwargs.get("data") or kwargs.get("json"))),
|
|
445
|
+
timestamp=time.time(),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
for attempt in range(self.retry_config.max_retries + 1):
|
|
449
|
+
start_time = time.time()
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
# Make the request
|
|
453
|
+
if method == "GET":
|
|
454
|
+
last_response = self.api_context.get(
|
|
455
|
+
full_url, headers=request_log.headers, params=kwargs.get("params")
|
|
456
|
+
)
|
|
457
|
+
elif method == "POST":
|
|
458
|
+
last_response = self.api_context.post(
|
|
459
|
+
full_url, data=kwargs.get("data"), headers=request_log.headers
|
|
460
|
+
)
|
|
461
|
+
elif method == "PUT":
|
|
462
|
+
last_response = self.api_context.put(
|
|
463
|
+
full_url, data=kwargs.get("data"), headers=request_log.headers
|
|
464
|
+
)
|
|
465
|
+
elif method == "DELETE":
|
|
466
|
+
last_response = self.api_context.delete(full_url, headers=request_log.headers)
|
|
467
|
+
elif method == "PATCH":
|
|
468
|
+
last_response = self.api_context.patch(
|
|
469
|
+
full_url, data=kwargs.get("data"), headers=request_log.headers
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
473
|
+
|
|
474
|
+
# Update log with response info
|
|
475
|
+
request_log.duration_ms = (time.time() - start_time) * 1000
|
|
476
|
+
request_log.status = last_response.status
|
|
477
|
+
request_log.response_headers = dict(last_response.headers)
|
|
478
|
+
|
|
479
|
+
# Try to get response body for logging
|
|
480
|
+
try:
|
|
481
|
+
body = last_response.body()
|
|
482
|
+
request_log.response_body = self._truncate_body(
|
|
483
|
+
body.decode("utf-8") if body else None
|
|
484
|
+
)
|
|
485
|
+
except Exception:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
# Check if we should retry
|
|
489
|
+
if attempt < self.retry_config.max_retries and self._should_retry(
|
|
490
|
+
last_response, None
|
|
491
|
+
):
|
|
492
|
+
backoff = self._calculate_backoff(attempt)
|
|
493
|
+
logger.warning(
|
|
494
|
+
f"Retry {attempt + 1}/{self.retry_config.max_retries} for {method} {endpoint} "
|
|
495
|
+
f"(status: {last_response.status}, backoff: {backoff:.2f}s)"
|
|
496
|
+
)
|
|
497
|
+
time.sleep(backoff)
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# Success or non-retryable response
|
|
501
|
+
self._log_request(request_log)
|
|
502
|
+
return last_response
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
last_exception = e
|
|
506
|
+
request_log.duration_ms = (time.time() - start_time) * 1000
|
|
507
|
+
|
|
508
|
+
# Check if we should retry on exception
|
|
509
|
+
if attempt < self.retry_config.max_retries and self._should_retry(None, e):
|
|
510
|
+
backoff = self._calculate_backoff(attempt)
|
|
511
|
+
logger.warning(
|
|
512
|
+
f"Retry {attempt + 1}/{self.retry_config.max_retries} for {method} {endpoint} "
|
|
513
|
+
f"(error: {e}, backoff: {backoff:.2f}s)"
|
|
514
|
+
)
|
|
515
|
+
time.sleep(backoff)
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
# Log failure
|
|
519
|
+
request_log.error = str(e)
|
|
520
|
+
self._log_request(request_log)
|
|
521
|
+
|
|
522
|
+
# Raise enhanced error
|
|
523
|
+
raise BasePageError(
|
|
524
|
+
message=f"Request failed after {attempt + 1} attempt(s): {e}",
|
|
525
|
+
url=full_url,
|
|
526
|
+
method=method,
|
|
527
|
+
request_log=request_log,
|
|
528
|
+
) from e
|
|
529
|
+
|
|
530
|
+
# Should not reach here, but just in case
|
|
531
|
+
self._log_request(request_log)
|
|
532
|
+
return last_response
|
|
533
|
+
|
|
534
|
+
# Public HTTP methods
|
|
535
|
+
|
|
536
|
+
def get(
|
|
537
|
+
self,
|
|
538
|
+
endpoint: str,
|
|
539
|
+
headers: Optional[Dict[str, str]] = None,
|
|
540
|
+
params: Optional[Dict[str, Any]] = None,
|
|
541
|
+
) -> APIResponse:
|
|
542
|
+
"""Perform a GET request.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
endpoint: API endpoint (e.g., "/users/123")
|
|
546
|
+
headers: Optional request-specific headers
|
|
547
|
+
params: Optional query parameters
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
APIResponse object
|
|
551
|
+
"""
|
|
552
|
+
return self._make_request("GET", endpoint, headers=headers, params=params)
|
|
553
|
+
|
|
554
|
+
def post(
|
|
555
|
+
self,
|
|
556
|
+
endpoint: str,
|
|
557
|
+
data: Optional[Dict[str, Any]] = None,
|
|
558
|
+
json: Optional[Dict[str, Any]] = None,
|
|
559
|
+
headers: Optional[Dict[str, str]] = None,
|
|
560
|
+
) -> APIResponse:
|
|
561
|
+
"""Perform a POST request.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
endpoint: API endpoint (e.g., "/users")
|
|
565
|
+
data: Form data (use either data or json, not both)
|
|
566
|
+
json: JSON payload (use either data or json, not both)
|
|
567
|
+
headers: Optional request-specific headers
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
APIResponse object
|
|
571
|
+
"""
|
|
572
|
+
if json:
|
|
573
|
+
return self._make_request("POST", endpoint, json=json, headers=headers)
|
|
574
|
+
return self._make_request("POST", endpoint, data=data, headers=headers)
|
|
575
|
+
|
|
576
|
+
def put(
|
|
577
|
+
self,
|
|
578
|
+
endpoint: str,
|
|
579
|
+
data: Optional[Dict[str, Any]] = None,
|
|
580
|
+
json: Optional[Dict[str, Any]] = None,
|
|
581
|
+
headers: Optional[Dict[str, str]] = None,
|
|
582
|
+
) -> APIResponse:
|
|
583
|
+
"""Perform a PUT request.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
endpoint: API endpoint (e.g., "/users/123")
|
|
587
|
+
data: Form data (use either data or json, not both)
|
|
588
|
+
json: JSON payload (use either data or json, not both)
|
|
589
|
+
headers: Optional request-specific headers
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
APIResponse object
|
|
593
|
+
"""
|
|
594
|
+
if json:
|
|
595
|
+
return self._make_request("PUT", endpoint, json=json, headers=headers)
|
|
596
|
+
return self._make_request("PUT", endpoint, data=data, headers=headers)
|
|
597
|
+
|
|
598
|
+
def delete(self, endpoint: str, headers: Optional[Dict[str, str]] = None) -> APIResponse:
|
|
599
|
+
"""Perform a DELETE request.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
endpoint: API endpoint (e.g., "/users/123")
|
|
603
|
+
headers: Optional request-specific headers
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
APIResponse object
|
|
607
|
+
"""
|
|
608
|
+
return self._make_request("DELETE", endpoint, headers=headers)
|
|
609
|
+
|
|
610
|
+
def patch(
|
|
611
|
+
self,
|
|
612
|
+
endpoint: str,
|
|
613
|
+
data: Optional[Dict[str, Any]] = None,
|
|
614
|
+
json: Optional[Dict[str, Any]] = None,
|
|
615
|
+
headers: Optional[Dict[str, str]] = None,
|
|
616
|
+
) -> APIResponse:
|
|
617
|
+
"""Perform a PATCH request.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
endpoint: API endpoint (e.g., "/users/123")
|
|
621
|
+
data: Form data (use either data or json, not both)
|
|
622
|
+
json: JSON payload (use either data or json, not both)
|
|
623
|
+
headers: Optional request-specific headers
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
APIResponse object
|
|
627
|
+
"""
|
|
628
|
+
if json:
|
|
629
|
+
return self._make_request("PATCH", endpoint, json=json, headers=headers)
|
|
630
|
+
return self._make_request("PATCH", endpoint, data=data, headers=headers)
|
|
631
|
+
|
|
632
|
+
# Helper methods for assertions
|
|
633
|
+
|
|
634
|
+
def assert_status(
|
|
635
|
+
self,
|
|
636
|
+
response: APIResponse,
|
|
637
|
+
expected_status: Union[int, List[int]],
|
|
638
|
+
message: Optional[str] = None,
|
|
639
|
+
) -> APIResponse:
|
|
640
|
+
"""Assert that response status matches expected.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
response: The API response to check
|
|
644
|
+
expected_status: Expected status code or list of acceptable codes
|
|
645
|
+
message: Optional custom error message
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
The response (for chaining)
|
|
649
|
+
|
|
650
|
+
Raises:
|
|
651
|
+
BasePageError: If status doesn't match
|
|
652
|
+
"""
|
|
653
|
+
if isinstance(expected_status, int):
|
|
654
|
+
expected_status = [expected_status]
|
|
655
|
+
|
|
656
|
+
if response.status not in expected_status:
|
|
657
|
+
try:
|
|
658
|
+
body_preview = response.text()[:500]
|
|
659
|
+
except Exception:
|
|
660
|
+
body_preview = "<unable to read body>"
|
|
661
|
+
|
|
662
|
+
error_msg = message or f"Expected status {expected_status}, got {response.status}"
|
|
663
|
+
raise BasePageError(
|
|
664
|
+
message=error_msg,
|
|
665
|
+
url=response.url,
|
|
666
|
+
status=response.status,
|
|
667
|
+
response_text=body_preview,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
return response
|
|
671
|
+
|
|
672
|
+
def assert_ok(self, response: APIResponse) -> APIResponse:
|
|
673
|
+
"""Assert that response status is 2xx (success).
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
response: The API response to check
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
The response (for chaining)
|
|
680
|
+
|
|
681
|
+
Raises:
|
|
682
|
+
BasePageError: If status is not 2xx
|
|
683
|
+
"""
|
|
684
|
+
if not (200 <= response.status < 300):
|
|
685
|
+
try:
|
|
686
|
+
body_preview = response.text()[:500]
|
|
687
|
+
except Exception:
|
|
688
|
+
body_preview = "<unable to read body>"
|
|
689
|
+
|
|
690
|
+
raise BasePageError(
|
|
691
|
+
message=f"Expected 2xx status, got {response.status}",
|
|
692
|
+
url=response.url,
|
|
693
|
+
status=response.status,
|
|
694
|
+
response_text=body_preview,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
return response
|
|
698
|
+
|
|
699
|
+
def assert_json(self, response: APIResponse, key: Optional[str] = None) -> Any:
|
|
700
|
+
"""Parse response as JSON with optional key extraction.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
response: The API response to parse
|
|
704
|
+
key: Optional key to extract from JSON (e.g., "data.user.name")
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Parsed JSON data, or value at key if specified
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
BasePageError: If JSON parsing fails or key not found
|
|
711
|
+
"""
|
|
712
|
+
try:
|
|
713
|
+
data = response.json()
|
|
714
|
+
except Exception as e:
|
|
715
|
+
raise BasePageError(
|
|
716
|
+
message=f"Failed to parse JSON response: {e}",
|
|
717
|
+
url=response.url,
|
|
718
|
+
status=response.status,
|
|
719
|
+
) from e
|
|
720
|
+
|
|
721
|
+
if key:
|
|
722
|
+
keys = key.split(".")
|
|
723
|
+
value = data
|
|
724
|
+
for k in keys:
|
|
725
|
+
if isinstance(value, dict) and k in value:
|
|
726
|
+
value = value[k]
|
|
727
|
+
else:
|
|
728
|
+
raise BasePageError(
|
|
729
|
+
message=f"Key '{key}' not found in response. Available keys: {list(value.keys()) if isinstance(value, dict) else 'N/A'}",
|
|
730
|
+
url=response.url,
|
|
731
|
+
status=response.status,
|
|
732
|
+
)
|
|
733
|
+
return value
|
|
734
|
+
|
|
735
|
+
return data
|
|
736
|
+
|
|
737
|
+
def assert_header(
|
|
738
|
+
self, response: APIResponse, header_name: str, expected_value: Optional[str] = None
|
|
739
|
+
) -> str:
|
|
740
|
+
"""Assert that response contains a specific header.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
response: The API response to check
|
|
744
|
+
header_name: Name of the header to check (case-insensitive)
|
|
745
|
+
expected_value: Optional expected value (if None, just checks existence)
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
The header value
|
|
749
|
+
|
|
750
|
+
Raises:
|
|
751
|
+
BasePageError: If header not found or value doesn't match
|
|
752
|
+
"""
|
|
753
|
+
headers = {k.lower(): v for k, v in response.headers.items()}
|
|
754
|
+
header_lower = header_name.lower()
|
|
755
|
+
|
|
756
|
+
if header_lower not in headers:
|
|
757
|
+
raise BasePageError(
|
|
758
|
+
message=f"Header '{header_name}' not found in response. Available headers: {list(response.headers.keys())}",
|
|
759
|
+
url=response.url,
|
|
760
|
+
status=response.status,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
value = headers[header_lower]
|
|
764
|
+
|
|
765
|
+
if expected_value and value != expected_value:
|
|
766
|
+
raise BasePageError(
|
|
767
|
+
message=f"Header '{header_name}' has value '{value}', expected '{expected_value}'",
|
|
768
|
+
url=response.url,
|
|
769
|
+
status=response.status,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
return value
|
|
773
|
+
|
|
774
|
+
# Utility methods
|
|
775
|
+
|
|
776
|
+
def get_response_text(self, response: APIResponse) -> str:
|
|
777
|
+
"""Get response text from Playwright APIResponse.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
response: The API response
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Response body as string
|
|
784
|
+
"""
|
|
785
|
+
return response.text()
|
|
786
|
+
|
|
787
|
+
def get_last_request(self) -> Optional[RequestLog]:
|
|
788
|
+
"""Get the most recent request log entry.
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
The last RequestLog or None if no requests made
|
|
792
|
+
"""
|
|
793
|
+
if self.request_history:
|
|
794
|
+
return self.request_history[-1]
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
def get_request_stats(self) -> Dict[str, Any]:
|
|
798
|
+
"""Get statistics about requests made.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
Dictionary with request statistics:
|
|
802
|
+
- total_requests: Total number of requests
|
|
803
|
+
- successful_requests: Number of 2xx responses
|
|
804
|
+
- failed_requests: Number of non-2xx responses
|
|
805
|
+
- total_duration_ms: Total time spent in requests
|
|
806
|
+
- average_duration_ms: Average request duration
|
|
807
|
+
- status_distribution: Count of each status code
|
|
808
|
+
"""
|
|
809
|
+
if not self.request_history:
|
|
810
|
+
return {
|
|
811
|
+
"total_requests": 0,
|
|
812
|
+
"successful_requests": 0,
|
|
813
|
+
"failed_requests": 0,
|
|
814
|
+
"total_duration_ms": 0.0,
|
|
815
|
+
"average_duration_ms": 0.0,
|
|
816
|
+
"status_distribution": {},
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
total_duration = sum(r.duration_ms for r in self.request_history)
|
|
820
|
+
status_counts = {}
|
|
821
|
+
successful = 0
|
|
822
|
+
failed = 0
|
|
823
|
+
|
|
824
|
+
for req in self.request_history:
|
|
825
|
+
if req.status:
|
|
826
|
+
status_counts[req.status] = status_counts.get(req.status, 0) + 1
|
|
827
|
+
if 200 <= req.status < 300:
|
|
828
|
+
successful += 1
|
|
829
|
+
else:
|
|
830
|
+
failed += 1
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
"total_requests": len(self.request_history),
|
|
834
|
+
"successful_requests": successful,
|
|
835
|
+
"failed_requests": failed,
|
|
836
|
+
"total_duration_ms": total_duration,
|
|
837
|
+
"average_duration_ms": total_duration / len(self.request_history),
|
|
838
|
+
"status_distribution": status_counts,
|
|
839
|
+
}
|