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.
@@ -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
+ }