api-forge-cli 1.0.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.
- api_forge/__init__.py +3 -0
- api_forge/__main__.py +6 -0
- api_forge/cli.py +277 -0
- api_forge/client.py +197 -0
- api_forge/mock_server.py +190 -0
- api_forge/models.py +296 -0
- api_forge/output.py +190 -0
- api_forge/parser.py +291 -0
- api_forge_cli-1.0.0.dist-info/METADATA +393 -0
- api_forge_cli-1.0.0.dist-info/RECORD +13 -0
- api_forge_cli-1.0.0.dist-info/WHEEL +4 -0
- api_forge_cli-1.0.0.dist-info/entry_points.txt +2 -0
- api_forge_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
api_forge/models.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Data models for api-forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpMethod(str, Enum):
|
|
11
|
+
"""HTTP methods."""
|
|
12
|
+
|
|
13
|
+
GET = "GET"
|
|
14
|
+
POST = "POST"
|
|
15
|
+
PUT = "PUT"
|
|
16
|
+
PATCH = "PATCH"
|
|
17
|
+
DELETE = "DELETE"
|
|
18
|
+
HEAD = "HEAD"
|
|
19
|
+
OPTIONS = "OPTIONS"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AssertionType(str, Enum):
|
|
23
|
+
"""Types of assertions for response validation."""
|
|
24
|
+
|
|
25
|
+
STATUS = "status"
|
|
26
|
+
HEADER = "header"
|
|
27
|
+
BODY_CONTAINS = "body_contains"
|
|
28
|
+
BODY_JSON_PATH = "json_path"
|
|
29
|
+
BODY_REGEX = "body_regex"
|
|
30
|
+
RESPONSE_TIME = "response_time"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Assertion:
|
|
35
|
+
"""A single assertion to validate a response."""
|
|
36
|
+
|
|
37
|
+
type: AssertionType
|
|
38
|
+
expected: Any
|
|
39
|
+
path: str | None = None # For JSON path or header name
|
|
40
|
+
actual: Any = None
|
|
41
|
+
passed: bool = False
|
|
42
|
+
message: str = ""
|
|
43
|
+
|
|
44
|
+
def evaluate(self, response: "RequestResult") -> None:
|
|
45
|
+
"""Evaluate this assertion against a response."""
|
|
46
|
+
import re
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
if self.type == AssertionType.STATUS:
|
|
50
|
+
self.actual = response.status_code
|
|
51
|
+
if isinstance(self.expected, list):
|
|
52
|
+
self.passed = self.actual in self.expected
|
|
53
|
+
else:
|
|
54
|
+
self.passed = self.actual == self.expected
|
|
55
|
+
self.message = f"Status {self.actual}" + ("" if self.passed else f" != expected {self.expected}")
|
|
56
|
+
|
|
57
|
+
elif self.type == AssertionType.HEADER:
|
|
58
|
+
self.actual = response.headers.get(self.path or "", "")
|
|
59
|
+
self.passed = self.expected.lower() in self.actual.lower() if self.actual else False
|
|
60
|
+
self.message = f"Header '{self.path}': '{self.actual}'" + ("" if self.passed else f" != expected '{self.expected}'")
|
|
61
|
+
|
|
62
|
+
elif self.type == AssertionType.BODY_CONTAINS:
|
|
63
|
+
self.actual = self.expected in response.body
|
|
64
|
+
self.passed = self.actual
|
|
65
|
+
self.message = f"Body contains '{self.expected}'" if self.passed else f"Body does not contain '{self.expected}'"
|
|
66
|
+
|
|
67
|
+
elif self.type == AssertionType.BODY_JSON_PATH:
|
|
68
|
+
self.actual = _json_path_extract(response.body, self.path or "")
|
|
69
|
+
self.passed = self.actual == self.expected
|
|
70
|
+
self.message = f"JSON path '{self.path}' = {self.actual}" + ("" if self.passed else f" != expected {self.expected}")
|
|
71
|
+
|
|
72
|
+
elif self.type == AssertionType.BODY_REGEX:
|
|
73
|
+
match = re.search(self.expected, response.body)
|
|
74
|
+
self.actual = match.group(0) if match else None
|
|
75
|
+
self.passed = match is not None
|
|
76
|
+
self.message = f"Regex '{self.expected}' " + ("matched" if self.passed else "not found")
|
|
77
|
+
|
|
78
|
+
elif self.type == AssertionType.RESPONSE_TIME:
|
|
79
|
+
self.actual = response.duration_ms
|
|
80
|
+
self.passed = self.actual <= self.expected
|
|
81
|
+
self.message = f"Response time {self.actual:.0f}ms" + ("" if self.passed else f" > max {self.expected}ms")
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
self.passed = False
|
|
85
|
+
self.message = f"Assertion error: {e}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _json_path_extract(body: str, path: str) -> Any:
|
|
89
|
+
"""Extract value from JSON body using dot notation path.
|
|
90
|
+
|
|
91
|
+
Example: "user.name" extracts {"user": {"name": "value"}} → "value"
|
|
92
|
+
"""
|
|
93
|
+
import json
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(body)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
parts = path.split(".")
|
|
101
|
+
current = data
|
|
102
|
+
for part in parts:
|
|
103
|
+
if isinstance(current, dict):
|
|
104
|
+
current = current.get(part)
|
|
105
|
+
elif isinstance(current, list):
|
|
106
|
+
try:
|
|
107
|
+
idx = int(part)
|
|
108
|
+
current = current[idx] if 0 <= idx < len(current) else None
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
111
|
+
else:
|
|
112
|
+
return None
|
|
113
|
+
if current is None:
|
|
114
|
+
return None
|
|
115
|
+
return current
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class RequestConfig:
|
|
120
|
+
"""Configuration for a single API request."""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
url: str
|
|
124
|
+
method: HttpMethod = HttpMethod.GET
|
|
125
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
126
|
+
body: str | dict | None = None
|
|
127
|
+
timeout: float = 30.0
|
|
128
|
+
assertions: list[Assertion] = field(default_factory=list)
|
|
129
|
+
variables: dict[str, str] = field(default_factory=dict) # Variables to extract from response
|
|
130
|
+
depends_on: str | None = None # Name of request this depends on
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class RequestResult:
|
|
135
|
+
"""Result of a single API request."""
|
|
136
|
+
|
|
137
|
+
name: str
|
|
138
|
+
url: str
|
|
139
|
+
method: str
|
|
140
|
+
status_code: int
|
|
141
|
+
headers: dict[str, str]
|
|
142
|
+
body: str
|
|
143
|
+
duration_ms: float
|
|
144
|
+
error: str | None = None
|
|
145
|
+
assertions: list[Assertion] = field(default_factory=list)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def success(self) -> bool:
|
|
149
|
+
"""Request succeeded (no error and all assertions passed)."""
|
|
150
|
+
if self.error:
|
|
151
|
+
return False
|
|
152
|
+
if not self.assertions:
|
|
153
|
+
return 200 <= self.status_code < 400
|
|
154
|
+
return all(a.passed for a in self.assertions)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def failed_assertions(self) -> list[Assertion]:
|
|
158
|
+
"""Get list of failed assertions."""
|
|
159
|
+
return [a for a in self.assertions if not a.passed]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class TestSuite:
|
|
164
|
+
"""A collection of API tests."""
|
|
165
|
+
|
|
166
|
+
name: str
|
|
167
|
+
base_url: str = ""
|
|
168
|
+
requests: list[RequestConfig] = field(default_factory=list)
|
|
169
|
+
variables: dict[str, str] = field(default_factory=dict) # Global variables
|
|
170
|
+
headers: dict[str, str] = field(default_factory=dict) # Default headers for all requests
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class TestSuiteResult:
|
|
175
|
+
"""Results of running a test suite."""
|
|
176
|
+
|
|
177
|
+
name: str
|
|
178
|
+
results: list[RequestResult] = field(default_factory=list)
|
|
179
|
+
total_duration_ms: float = 0.0
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def total(self) -> int:
|
|
183
|
+
return len(self.results)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def passed(self) -> int:
|
|
187
|
+
return sum(1 for r in self.results if r.success)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def failed(self) -> int:
|
|
191
|
+
return self.total - self.passed
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def success_rate(self) -> float:
|
|
195
|
+
return (self.passed / self.total * 100) if self.total else 0.0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class LoadTestConfig:
|
|
200
|
+
"""Configuration for load testing."""
|
|
201
|
+
|
|
202
|
+
url: str
|
|
203
|
+
method: HttpMethod = HttpMethod.GET
|
|
204
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
205
|
+
body: str | dict | None = None
|
|
206
|
+
concurrency: int = 10
|
|
207
|
+
requests_per_second: int = 0 # 0 = as fast as possible
|
|
208
|
+
duration_seconds: int = 10
|
|
209
|
+
timeout: float = 30.0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class LoadTestResult:
|
|
214
|
+
"""Results of a load test run."""
|
|
215
|
+
|
|
216
|
+
config: LoadTestConfig
|
|
217
|
+
total_requests: int = 0
|
|
218
|
+
successful_requests: int = 0
|
|
219
|
+
failed_requests: int = 0
|
|
220
|
+
latencies_ms: list[float] = field(default_factory=list)
|
|
221
|
+
status_codes: dict[int, int] = field(default_factory=dict)
|
|
222
|
+
errors: list[str] = field(default_factory=list)
|
|
223
|
+
duration_seconds: float = 0.0
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def requests_per_second(self) -> float:
|
|
227
|
+
return self.total_requests / self.duration_seconds if self.duration_seconds > 0 else 0.0
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def avg_latency_ms(self) -> float:
|
|
231
|
+
return sum(self.latencies_ms) / len(self.latencies_ms) if self.latencies_ms else 0.0
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def min_latency_ms(self) -> float:
|
|
235
|
+
return min(self.latencies_ms) if self.latencies_ms else 0.0
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def max_latency_ms(self) -> float:
|
|
239
|
+
return max(self.latencies_ms) if self.latencies_ms else 0.0
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def p50_latency_ms(self) -> float:
|
|
243
|
+
return _percentile(self.latencies_ms, 50)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def p95_latency_ms(self) -> float:
|
|
247
|
+
return _percentile(self.latencies_ms, 95)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def p99_latency_ms(self) -> float:
|
|
251
|
+
return _percentile(self.latencies_ms, 99)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def success_rate(self) -> float:
|
|
255
|
+
return (self.successful_requests / self.total_requests * 100) if self.total_requests else 0.0
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def error_rate(self) -> float:
|
|
259
|
+
return 100.0 - self.success_rate
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _percentile(values: list[float], p: int) -> float:
|
|
263
|
+
"""Calculate the p-th percentile of values."""
|
|
264
|
+
if not values:
|
|
265
|
+
return 0.0
|
|
266
|
+
sorted_vals = sorted(values)
|
|
267
|
+
idx = int(len(sorted_vals) * p / 100)
|
|
268
|
+
idx = min(idx, len(sorted_vals) - 1)
|
|
269
|
+
return sorted_vals[idx]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class MockEndpoint:
|
|
274
|
+
"""Configuration for a mock endpoint."""
|
|
275
|
+
|
|
276
|
+
path: str
|
|
277
|
+
method: HttpMethod = HttpMethod.GET
|
|
278
|
+
status_code: int = 200
|
|
279
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
280
|
+
body: str | dict = ""
|
|
281
|
+
delay_ms: int = 0 # Simulate latency
|
|
282
|
+
match_headers: dict[str, str] = field(default_factory=dict) # Request must have these headers
|
|
283
|
+
match_body: str | None = None # Request body must contain this
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class MockServerConfig:
|
|
288
|
+
"""Configuration for a mock server."""
|
|
289
|
+
|
|
290
|
+
name: str = "api-forge-mock"
|
|
291
|
+
host: str = "127.0.0.1"
|
|
292
|
+
port: int = 8000
|
|
293
|
+
endpoints: list[MockEndpoint] = field(default_factory=list)
|
|
294
|
+
default_status: int = 404
|
|
295
|
+
default_body: str = '{"error": "Not Found"}'
|
|
296
|
+
cors: bool = True
|
api_forge/output.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Rich console output for api-forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich import box
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from .models import LoadTestResult, RequestResult, TestSuiteResult
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_request_result(result: RequestResult) -> None:
|
|
17
|
+
"""Render a single request result."""
|
|
18
|
+
status_color = "green" if result.success else "red"
|
|
19
|
+
status_icon = "✓" if result.success else "✗"
|
|
20
|
+
|
|
21
|
+
# Header
|
|
22
|
+
console.print(f" [{status_color}]{status_icon}[/] [bold]{result.name}[/]")
|
|
23
|
+
console.print(f" [dim]{result.method} {result.url}[/]")
|
|
24
|
+
|
|
25
|
+
if result.error:
|
|
26
|
+
console.print(f" [red]Error: {result.error}[/]")
|
|
27
|
+
else:
|
|
28
|
+
console.print(f" [dim]Status: {result.status_code} | Time: {result.duration_ms:.0f}ms[/]")
|
|
29
|
+
|
|
30
|
+
# Assertions
|
|
31
|
+
if result.assertions:
|
|
32
|
+
for a in result.assertions:
|
|
33
|
+
a_color = "green" if a.passed else "red"
|
|
34
|
+
a_icon = "✓" if a.passed else "✗"
|
|
35
|
+
console.print(f" [{a_color}]{a_icon}[/] {a.message}")
|
|
36
|
+
|
|
37
|
+
console.print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_test_suite_result(result: TestSuiteResult) -> None:
|
|
41
|
+
"""Render full test suite results."""
|
|
42
|
+
console.print()
|
|
43
|
+
|
|
44
|
+
# Header
|
|
45
|
+
header = Panel(
|
|
46
|
+
Text.from_markup(f"[bold]🔧 API Test Suite: {result.name}[/]"),
|
|
47
|
+
box=box.ROUNDED,
|
|
48
|
+
padding=(0, 2),
|
|
49
|
+
)
|
|
50
|
+
console.print(header)
|
|
51
|
+
console.print()
|
|
52
|
+
|
|
53
|
+
# Individual results
|
|
54
|
+
for req_result in result.results:
|
|
55
|
+
render_request_result(req_result)
|
|
56
|
+
|
|
57
|
+
# Summary
|
|
58
|
+
passed_color = "green" if result.passed == result.total else "yellow"
|
|
59
|
+
failed_color = "red" if result.failed > 0 else "dim"
|
|
60
|
+
|
|
61
|
+
summary = Table(show_header=False, box=None, padding=(0, 2))
|
|
62
|
+
summary.add_column()
|
|
63
|
+
summary.add_column(justify="right")
|
|
64
|
+
|
|
65
|
+
summary.add_row("[bold]Total Requests[/]", str(result.total))
|
|
66
|
+
summary.add_row(f"[{passed_color}]Passed[/]", f"[{passed_color}]{result.passed}[/]")
|
|
67
|
+
summary.add_row(f"[{failed_color}]Failed[/]", f"[{failed_color}]{result.failed}[/]")
|
|
68
|
+
summary.add_row("[dim]Duration[/]", f"[dim]{result.total_duration_ms:.0f}ms[/]")
|
|
69
|
+
summary.add_row("[bold]Success Rate[/]", f"[bold]{result.success_rate:.1f}%[/]")
|
|
70
|
+
|
|
71
|
+
console.print(Panel(summary, title="Summary", box=box.ROUNDED))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_load_test_result(result: LoadTestResult) -> None:
|
|
75
|
+
"""Render load test results."""
|
|
76
|
+
console.print()
|
|
77
|
+
|
|
78
|
+
# Header
|
|
79
|
+
header = Panel(
|
|
80
|
+
Text.from_markup(f"[bold]⚡ Load Test Results[/]\n[dim]{result.config.method.value} {result.config.url}[/]"),
|
|
81
|
+
box=box.ROUNDED,
|
|
82
|
+
padding=(0, 2),
|
|
83
|
+
)
|
|
84
|
+
console.print(header)
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
# Main metrics
|
|
88
|
+
metrics = Table(show_header=False, box=None, padding=(0, 2))
|
|
89
|
+
metrics.add_column(width=20)
|
|
90
|
+
metrics.add_column(justify="right")
|
|
91
|
+
|
|
92
|
+
success_color = "green" if result.success_rate >= 99 else ("yellow" if result.success_rate >= 95 else "red")
|
|
93
|
+
|
|
94
|
+
metrics.add_row("[bold]Total Requests[/]", f"{result.total_requests:,}")
|
|
95
|
+
metrics.add_row(f"[{success_color}]Successful[/]", f"[{success_color}]{result.successful_requests:,}[/]")
|
|
96
|
+
metrics.add_row("[red]Failed[/]" if result.failed_requests else "[dim]Failed[/]", f"[red]{result.failed_requests:,}[/]" if result.failed_requests else f"[dim]{result.failed_requests}[/]")
|
|
97
|
+
metrics.add_row("[bold]Duration[/]", f"{result.duration_seconds:.1f}s")
|
|
98
|
+
metrics.add_row("[bold]Requests/sec[/]", f"[cyan]{result.requests_per_second:.1f}[/]")
|
|
99
|
+
metrics.add_row(f"[{success_color}]Success Rate[/]", f"[{success_color}]{result.success_rate:.2f}%[/]")
|
|
100
|
+
|
|
101
|
+
console.print(Panel(metrics, title="Overview", box=box.ROUNDED))
|
|
102
|
+
console.print()
|
|
103
|
+
|
|
104
|
+
# Latency stats
|
|
105
|
+
if result.latencies_ms:
|
|
106
|
+
latency = Table(show_header=False, box=None, padding=(0, 2))
|
|
107
|
+
latency.add_column(width=20)
|
|
108
|
+
latency.add_column(justify="right")
|
|
109
|
+
|
|
110
|
+
latency.add_row("[dim]Min[/]", f"[dim]{result.min_latency_ms:.1f}ms[/]")
|
|
111
|
+
latency.add_row("[bold]Average[/]", f"{result.avg_latency_ms:.1f}ms")
|
|
112
|
+
latency.add_row("[bold]P50 (Median)[/]", f"{result.p50_latency_ms:.1f}ms")
|
|
113
|
+
latency.add_row("[yellow]P95[/]", f"[yellow]{result.p95_latency_ms:.1f}ms[/]")
|
|
114
|
+
latency.add_row("[red]P99[/]", f"[red]{result.p99_latency_ms:.1f}ms[/]")
|
|
115
|
+
latency.add_row("[dim]Max[/]", f"[dim]{result.max_latency_ms:.1f}ms[/]")
|
|
116
|
+
|
|
117
|
+
console.print(Panel(latency, title="Latency", box=box.ROUNDED))
|
|
118
|
+
console.print()
|
|
119
|
+
|
|
120
|
+
# Status codes
|
|
121
|
+
if result.status_codes:
|
|
122
|
+
status_table = Table(show_header=True, box=box.SIMPLE)
|
|
123
|
+
status_table.add_column("Status", style="bold")
|
|
124
|
+
status_table.add_column("Count", justify="right")
|
|
125
|
+
status_table.add_column("Percent", justify="right")
|
|
126
|
+
|
|
127
|
+
for status, count in sorted(result.status_codes.items()):
|
|
128
|
+
pct = count / result.total_requests * 100
|
|
129
|
+
color = "green" if 200 <= status < 300 else ("yellow" if 300 <= status < 400 else "red")
|
|
130
|
+
status_table.add_row(f"[{color}]{status}[/]", f"{count:,}", f"{pct:.1f}%")
|
|
131
|
+
|
|
132
|
+
console.print(Panel(status_table, title="Status Codes", box=box.ROUNDED))
|
|
133
|
+
console.print()
|
|
134
|
+
|
|
135
|
+
# Errors
|
|
136
|
+
if result.errors:
|
|
137
|
+
console.print(Panel(f"[red]Sample Errors ({len(result.errors)} shown):[/]\n" + "\n".join(f" • {e}" for e in result.errors[:5]), title="Errors", box=box.ROUNDED, border_style="red"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_quick_request(result: RequestResult) -> None:
|
|
141
|
+
"""Render a quick/single request result."""
|
|
142
|
+
console.print()
|
|
143
|
+
|
|
144
|
+
status_color = "green" if 200 <= result.status_code < 300 else ("yellow" if 300 <= result.status_code < 400 else "red")
|
|
145
|
+
|
|
146
|
+
header = Text()
|
|
147
|
+
header.append(f"{result.method} ", style="bold")
|
|
148
|
+
header.append(result.url, style="dim")
|
|
149
|
+
|
|
150
|
+
console.print(Panel(header, box=box.ROUNDED))
|
|
151
|
+
console.print()
|
|
152
|
+
|
|
153
|
+
# Status line
|
|
154
|
+
console.print(f" Status: [{status_color}][bold]{result.status_code}[/][/]")
|
|
155
|
+
console.print(f" Time: [cyan]{result.duration_ms:.0f}ms[/]")
|
|
156
|
+
|
|
157
|
+
if result.error:
|
|
158
|
+
console.print(f" [red]Error: {result.error}[/]")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Headers (selected)
|
|
162
|
+
if result.headers:
|
|
163
|
+
console.print("\n [bold]Headers:[/]")
|
|
164
|
+
important_headers = ["content-type", "content-length", "cache-control", "x-request-id", "x-response-time"]
|
|
165
|
+
for h in important_headers:
|
|
166
|
+
if h in result.headers:
|
|
167
|
+
console.print(f" [dim]{h}:[/] {result.headers[h]}")
|
|
168
|
+
|
|
169
|
+
# Body preview
|
|
170
|
+
if result.body:
|
|
171
|
+
console.print("\n [bold]Body:[/]")
|
|
172
|
+
body_preview = result.body[:500]
|
|
173
|
+
if len(result.body) > 500:
|
|
174
|
+
body_preview += "..."
|
|
175
|
+
|
|
176
|
+
# Try to pretty-print JSON
|
|
177
|
+
import json
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
parsed = json.loads(result.body)
|
|
181
|
+
body_preview = json.dumps(parsed, indent=2)[:500]
|
|
182
|
+
if len(json.dumps(parsed, indent=2)) > 500:
|
|
183
|
+
body_preview += "\n..."
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
for line in body_preview.split("\n"):
|
|
188
|
+
console.print(f" [dim]{line}[/]")
|
|
189
|
+
|
|
190
|
+
console.print()
|