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/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()