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 ADDED
@@ -0,0 +1,3 @@
1
+ """api-forge — API Testing & Mock Server CLI."""
2
+
3
+ __version__ = "1.0.0"
api_forge/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as python -m api_forge."""
2
+
3
+ from api_forge.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
api_forge/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """CLI entry point for api-forge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from . import __version__
12
+ from .client import execute_request, run_load_test, run_test_suite
13
+ from .mock_server import run_mock_server_blocking
14
+ from .models import (
15
+ HttpMethod,
16
+ LoadTestConfig,
17
+ MockServerConfig,
18
+ RequestConfig,
19
+ )
20
+ from .output import (
21
+ console,
22
+ render_load_test_result,
23
+ render_quick_request,
24
+ render_test_suite_result,
25
+ )
26
+ from .parser import (
27
+ generate_example_load_test,
28
+ generate_example_mock_config,
29
+ generate_example_suite,
30
+ parse_load_test_config,
31
+ parse_mock_config,
32
+ parse_test_suite,
33
+ )
34
+
35
+
36
+ @click.group()
37
+ @click.version_option(__version__, prog_name="api-forge")
38
+ def cli():
39
+ """🔧 api-forge — API Testing & Mock Server CLI.
40
+
41
+ Test APIs, run load tests, and create mock servers from YAML
42
+ configuration files.
43
+
44
+ Examples:
45
+
46
+ api-forge test suite.yaml
47
+
48
+ api-forge load config.yaml
49
+
50
+ api-forge mock mock.yaml
51
+
52
+ api-forge get https://api.example.com/users
53
+ """
54
+
55
+
56
+ @cli.command("test")
57
+ @click.argument("file", type=click.Path(exists=True))
58
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
59
+ def test_cmd(file: str, verbose: bool):
60
+ """Run API tests from a YAML test suite.
61
+
62
+ Examples:
63
+
64
+ api-forge test suite.yaml
65
+
66
+ api-forge test tests/api-tests.yaml -v
67
+ """
68
+ try:
69
+ suite = parse_test_suite(file)
70
+ except Exception as e:
71
+ console.print(f"[red]Failed to parse test suite: {e}[/]")
72
+ sys.exit(1)
73
+
74
+ console.print(f"[dim]Running test suite: {suite.name}[/]")
75
+
76
+ result = asyncio.run(run_test_suite(suite))
77
+ render_test_suite_result(result)
78
+
79
+ if result.failed > 0:
80
+ sys.exit(1)
81
+
82
+
83
+ @cli.command("load")
84
+ @click.argument("target", required=False)
85
+ @click.option("--config", "-c", type=click.Path(exists=True), help="Load test config YAML file")
86
+ @click.option("--concurrency", "-n", default=10, help="Number of concurrent connections")
87
+ @click.option("--duration", "-d", default=10, help="Test duration in seconds")
88
+ @click.option("--rps", default=0, help="Requests per second (0 = unlimited)")
89
+ def load_cmd(target: str | None, config: str | None, concurrency: int, duration: int, rps: int):
90
+ """Run load tests against an API endpoint.
91
+
92
+ Examples:
93
+
94
+ api-forge load https://api.example.com/health
95
+
96
+ api-forge load -c loadtest.yaml
97
+
98
+ api-forge load https://api.example.com/users -n 50 -d 30
99
+ """
100
+ if config:
101
+ try:
102
+ load_config = parse_load_test_config(config)
103
+ except Exception as e:
104
+ console.print(f"[red]Failed to parse config: {e}[/]")
105
+ sys.exit(1)
106
+ elif target:
107
+ load_config = LoadTestConfig(
108
+ url=target,
109
+ concurrency=concurrency,
110
+ duration_seconds=duration,
111
+ requests_per_second=rps,
112
+ )
113
+ else:
114
+ console.print("[red]Please provide a URL or --config file[/]")
115
+ sys.exit(1)
116
+
117
+ console.print(f"[dim]Starting load test: {load_config.url}[/]")
118
+ console.print(f"[dim]Concurrency: {load_config.concurrency} | Duration: {load_config.duration_seconds}s[/]\n")
119
+
120
+ result = asyncio.run(run_load_test(load_config))
121
+ render_load_test_result(result)
122
+
123
+
124
+ @cli.command("mock")
125
+ @click.argument("file", type=click.Path(exists=True), required=False)
126
+ @click.option("--port", "-p", default=8000, help="Server port")
127
+ @click.option("--host", "-h", "host", default="127.0.0.1", help="Server host")
128
+ def mock_cmd(file: str | None, port: int, host: str):
129
+ """Start a mock API server from YAML configuration.
130
+
131
+ Examples:
132
+
133
+ api-forge mock mock.yaml
134
+
135
+ api-forge mock mock.yaml -p 3000
136
+
137
+ api-forge mock # Starts default server on :8000
138
+ """
139
+ if file:
140
+ try:
141
+ config = parse_mock_config(file)
142
+ config.port = port # CLI override
143
+ config.host = host
144
+ except Exception as e:
145
+ console.print(f"[red]Failed to parse mock config: {e}[/]")
146
+ sys.exit(1)
147
+ else:
148
+ # Default mock server
149
+ config = MockServerConfig(host=host, port=port)
150
+
151
+ console.print(f"[bold green]🚀 Mock server running at http://{config.host}:{config.port}[/]")
152
+ console.print(f"[dim]Endpoints: {len(config.endpoints)}[/]")
153
+ console.print("[dim]Press Ctrl+C to stop[/]\n")
154
+
155
+ try:
156
+ run_mock_server_blocking(config)
157
+ except KeyboardInterrupt:
158
+ console.print("\n[yellow]Server stopped[/]")
159
+
160
+
161
+ @cli.command("get")
162
+ @click.argument("url")
163
+ @click.option("--header", "-H", multiple=True, help="Headers (key:value)")
164
+ @click.option("--timeout", "-t", default=30.0, help="Request timeout")
165
+ def get_cmd(url: str, header: tuple, timeout: float):
166
+ """Make a GET request to a URL.
167
+
168
+ Examples:
169
+
170
+ api-forge get https://api.example.com/users
171
+
172
+ api-forge get https://api.example.com/users -H "Authorization:Bearer token"
173
+ """
174
+ _quick_request(url, HttpMethod.GET, header, None, timeout)
175
+
176
+
177
+ @cli.command("post")
178
+ @click.argument("url")
179
+ @click.option("--data", "-d", help="Request body (JSON string)")
180
+ @click.option("--header", "-H", multiple=True, help="Headers (key:value)")
181
+ @click.option("--timeout", "-t", default=30.0, help="Request timeout")
182
+ def post_cmd(url: str, data: str | None, header: tuple, timeout: float):
183
+ """Make a POST request to a URL.
184
+
185
+ Examples:
186
+
187
+ api-forge post https://api.example.com/users -d '{"name":"Alice"}'
188
+
189
+ api-forge post https://api.example.com/login -d '{"user":"admin"}' -H "Content-Type:application/json"
190
+ """
191
+ _quick_request(url, HttpMethod.POST, header, data, timeout)
192
+
193
+
194
+ @cli.command("put")
195
+ @click.argument("url")
196
+ @click.option("--data", "-d", help="Request body (JSON string)")
197
+ @click.option("--header", "-H", multiple=True, help="Headers (key:value)")
198
+ @click.option("--timeout", "-t", default=30.0, help="Request timeout")
199
+ def put_cmd(url: str, data: str | None, header: tuple, timeout: float):
200
+ """Make a PUT request to a URL.
201
+
202
+ Examples:
203
+
204
+ api-forge put https://api.example.com/users/1 -d '{"name":"Bob"}'
205
+ """
206
+ _quick_request(url, HttpMethod.PUT, header, data, timeout)
207
+
208
+
209
+ @cli.command("delete")
210
+ @click.argument("url")
211
+ @click.option("--header", "-H", multiple=True, help="Headers (key:value)")
212
+ @click.option("--timeout", "-t", default=30.0, help="Request timeout")
213
+ def delete_cmd(url: str, header: tuple, timeout: float):
214
+ """Make a DELETE request to a URL.
215
+
216
+ Examples:
217
+
218
+ api-forge delete https://api.example.com/users/1
219
+ """
220
+ _quick_request(url, HttpMethod.DELETE, header, None, timeout)
221
+
222
+
223
+ @cli.command("init")
224
+ @click.option("--type", "init_type", type=click.Choice(["test", "load", "mock"]), default="test", help="Type of config to generate")
225
+ @click.option("--output", "-o", help="Output file path")
226
+ def init_cmd(init_type: str, output: str | None):
227
+ """Generate example configuration files.
228
+
229
+ Examples:
230
+
231
+ api-forge init --type test -o suite.yaml
232
+
233
+ api-forge init --type load -o loadtest.yaml
234
+
235
+ api-forge init --type mock -o mock.yaml
236
+ """
237
+ generators = {
238
+ "test": ("test-suite.yaml", generate_example_suite),
239
+ "load": ("load-test.yaml", generate_example_load_test),
240
+ "mock": ("mock-server.yaml", generate_example_mock_config),
241
+ }
242
+
243
+ default_name, generator = generators[init_type]
244
+ content = generator()
245
+ output_path = output or default_name
246
+
247
+ Path(output_path).write_text(content, encoding="utf-8")
248
+ console.print(f"[green]Created {output_path}[/]")
249
+
250
+
251
+ def _quick_request(url: str, method: HttpMethod, headers: tuple, data: str | None, timeout: float) -> None:
252
+ """Execute a quick HTTP request."""
253
+ # Parse headers
254
+ parsed_headers = {}
255
+ for h in headers:
256
+ if ":" in h:
257
+ key, value = h.split(":", 1)
258
+ parsed_headers[key.strip()] = value.strip()
259
+
260
+ config = RequestConfig(
261
+ name="Quick Request",
262
+ url=url,
263
+ method=method,
264
+ headers=parsed_headers,
265
+ body=data,
266
+ timeout=timeout,
267
+ )
268
+
269
+ result = asyncio.run(execute_request(config))
270
+ render_quick_request(result)
271
+
272
+ if result.error or result.status_code >= 400:
273
+ sys.exit(1)
274
+
275
+
276
+ if __name__ == "__main__":
277
+ cli()
api_forge/client.py ADDED
@@ -0,0 +1,197 @@
1
+ """HTTP client for API testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+
9
+ import httpx
10
+
11
+ from .models import (
12
+ LoadTestConfig,
13
+ LoadTestResult,
14
+ RequestConfig,
15
+ RequestResult,
16
+ TestSuite,
17
+ TestSuiteResult,
18
+ )
19
+
20
+
21
+ async def execute_request(
22
+ config: RequestConfig,
23
+ base_url: str = "",
24
+ global_headers: dict[str, str] | None = None,
25
+ variables: dict[str, str] | None = None,
26
+ ) -> RequestResult:
27
+ """Execute a single API request."""
28
+ # Resolve URL
29
+ url = config.url
30
+ if base_url and not url.startswith(("http://", "https://")):
31
+ url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
32
+
33
+ # Variable substitution
34
+ all_vars = {**(variables or {}), **config.variables}
35
+ url = _substitute_variables(url, all_vars)
36
+
37
+ # Merge headers
38
+ headers = {**(global_headers or {}), **config.headers}
39
+ headers = {k: _substitute_variables(v, all_vars) for k, v in headers.items()}
40
+
41
+ # Prepare body
42
+ body = config.body
43
+ if isinstance(body, dict):
44
+ body = json.dumps(body)
45
+ elif isinstance(body, str):
46
+ body = _substitute_variables(body, all_vars)
47
+
48
+ start = time.perf_counter()
49
+ result = RequestResult(
50
+ name=config.name,
51
+ url=url,
52
+ method=config.method.value,
53
+ status_code=0,
54
+ headers={},
55
+ body="",
56
+ duration_ms=0.0,
57
+ )
58
+
59
+ try:
60
+ async with httpx.AsyncClient(timeout=config.timeout, follow_redirects=True) as client:
61
+ response = await client.request(
62
+ method=config.method.value,
63
+ url=url,
64
+ headers=headers,
65
+ content=body,
66
+ )
67
+
68
+ result.status_code = response.status_code
69
+ result.headers = dict(response.headers)
70
+ result.body = response.text
71
+
72
+ except httpx.TimeoutException:
73
+ result.error = "Request timed out"
74
+ except httpx.ConnectError as e:
75
+ result.error = f"Connection failed: {e}"
76
+ except Exception as e:
77
+ result.error = f"Request failed: {e}"
78
+
79
+ result.duration_ms = (time.perf_counter() - start) * 1000
80
+
81
+ # Run assertions
82
+ for assertion in config.assertions:
83
+ assertion.evaluate(result)
84
+ result.assertions.append(assertion)
85
+
86
+ return result
87
+
88
+
89
+ async def run_test_suite(suite: TestSuite) -> TestSuiteResult:
90
+ """Run all tests in a test suite."""
91
+ start = time.perf_counter()
92
+ result = TestSuiteResult(name=suite.name)
93
+
94
+ variables = dict(suite.variables)
95
+
96
+ for request in suite.requests:
97
+ req_result = await execute_request(
98
+ request,
99
+ base_url=suite.base_url,
100
+ global_headers=suite.headers,
101
+ variables=variables,
102
+ )
103
+ result.results.append(req_result)
104
+
105
+ # Extract variables from response for chaining
106
+ for var_name, json_path in request.variables.items():
107
+ from .models import _json_path_extract
108
+
109
+ extracted = _json_path_extract(req_result.body, json_path)
110
+ if extracted is not None:
111
+ variables[var_name] = str(extracted)
112
+
113
+ result.total_duration_ms = (time.perf_counter() - start) * 1000
114
+ return result
115
+
116
+
117
+ async def run_load_test(config: LoadTestConfig) -> LoadTestResult:
118
+ """Run a load test against an endpoint."""
119
+ result = LoadTestResult(config=config)
120
+ start = time.perf_counter()
121
+ end_time = start + config.duration_seconds
122
+
123
+ # Prepare body
124
+ body = config.body
125
+ if isinstance(body, dict):
126
+ body = json.dumps(body)
127
+
128
+ semaphore = asyncio.Semaphore(config.concurrency)
129
+ tasks: list[asyncio.Task] = []
130
+
131
+ async def make_request() -> tuple[int, float, str | None]:
132
+ async with semaphore:
133
+ req_start = time.perf_counter()
134
+ try:
135
+ async with httpx.AsyncClient(timeout=config.timeout) as client:
136
+ response = await client.request(
137
+ method=config.method.value,
138
+ url=config.url,
139
+ headers=config.headers,
140
+ content=body,
141
+ )
142
+ duration_ms = (time.perf_counter() - req_start) * 1000
143
+ return response.status_code, duration_ms, None
144
+ except Exception as e:
145
+ duration_ms = (time.perf_counter() - req_start) * 1000
146
+ return 0, duration_ms, str(e)
147
+
148
+ # Generate requests until duration expires
149
+ request_count = 0
150
+ delay = 1.0 / config.requests_per_second if config.requests_per_second > 0 else 0
151
+
152
+ while time.perf_counter() < end_time:
153
+ tasks.append(asyncio.create_task(make_request()))
154
+ request_count += 1
155
+
156
+ if delay > 0:
157
+ await asyncio.sleep(delay)
158
+ else:
159
+ # Batch to avoid overwhelming event loop
160
+ if request_count % 100 == 0:
161
+ await asyncio.sleep(0.001)
162
+
163
+ # Wait for all requests to complete
164
+ responses = await asyncio.gather(*tasks, return_exceptions=True)
165
+
166
+ result.duration_seconds = time.perf_counter() - start
167
+
168
+ for resp in responses:
169
+ if isinstance(resp, Exception):
170
+ result.failed_requests += 1
171
+ result.errors.append(str(resp))
172
+ continue
173
+
174
+ status, latency, error = resp
175
+ result.total_requests += 1
176
+ result.latencies_ms.append(latency)
177
+
178
+ if error:
179
+ result.failed_requests += 1
180
+ if len(result.errors) < 10: # Cap error samples
181
+ result.errors.append(error)
182
+ else:
183
+ result.successful_requests += 1
184
+ result.status_codes[status] = result.status_codes.get(status, 0) + 1
185
+
186
+ return result
187
+
188
+
189
+ def _substitute_variables(text: str, variables: dict[str, str]) -> str:
190
+ """Replace {{var}} placeholders with variable values."""
191
+ import re
192
+
193
+ def replacer(match: re.Match) -> str:
194
+ var_name = match.group(1)
195
+ return variables.get(var_name, match.group(0))
196
+
197
+ return re.sub(r"\{\{(\w+)\}\}", replacer, text)
@@ -0,0 +1,190 @@
1
+ """Mock HTTP server for API testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from threading import Thread
9
+ from typing import Any
10
+
11
+ from .models import MockEndpoint, MockServerConfig
12
+
13
+
14
+ class MockHandler(BaseHTTPRequestHandler):
15
+ """HTTP request handler for mock server."""
16
+
17
+ server_config: MockServerConfig
18
+
19
+ def log_message(self, format: str, *args: Any) -> None:
20
+ """Suppress default logging."""
21
+ pass
22
+
23
+ def _handle_request(self, method: str) -> None:
24
+ """Handle an incoming request."""
25
+ config = self.server_config
26
+ path = self.path.split("?")[0] # Remove query string
27
+
28
+ # Read request body if present
29
+ content_length = int(self.headers.get("Content-Length", 0))
30
+ body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else ""
31
+
32
+ # Find matching endpoint
33
+ endpoint = self._find_endpoint(path, method, body)
34
+
35
+ if endpoint:
36
+ # Simulate latency
37
+ if endpoint.delay_ms > 0:
38
+ time.sleep(endpoint.delay_ms / 1000)
39
+
40
+ self._send_response(endpoint.status_code, endpoint.headers, endpoint.body)
41
+ else:
42
+ self._send_response(config.default_status, {}, config.default_body)
43
+
44
+ def _find_endpoint(self, path: str, method: str, body: str) -> MockEndpoint | None:
45
+ """Find a matching mock endpoint."""
46
+ for ep in self.server_config.endpoints:
47
+ # Check path match
48
+ if not self._path_matches(path, ep.path):
49
+ continue
50
+
51
+ # Check method match
52
+ if ep.method.value != method:
53
+ continue
54
+
55
+ # Check header requirements
56
+ if ep.match_headers:
57
+ headers_match = all(self.headers.get(k, "").lower() == v.lower() for k, v in ep.match_headers.items())
58
+ if not headers_match:
59
+ continue
60
+
61
+ # Check body requirements
62
+ if ep.match_body and ep.match_body not in body:
63
+ continue
64
+
65
+ return ep
66
+
67
+ return None
68
+
69
+ def _path_matches(self, request_path: str, pattern: str) -> bool:
70
+ """Check if request path matches the pattern.
71
+
72
+ Supports simple wildcards: /users/* matches /users/123
73
+ """
74
+ if pattern == request_path:
75
+ return True
76
+
77
+ if "*" in pattern:
78
+ pattern_parts = pattern.split("/")
79
+ request_parts = request_path.split("/")
80
+
81
+ if len(pattern_parts) != len(request_parts):
82
+ return False
83
+
84
+ for pp, rp in zip(pattern_parts, request_parts):
85
+ if pp != "*" and pp != rp:
86
+ return False
87
+ return True
88
+
89
+ return False
90
+
91
+ def _send_response(self, status: int, headers: dict[str, str], body: str | dict) -> None:
92
+ """Send HTTP response."""
93
+ self.send_response(status)
94
+
95
+ # CORS headers if enabled
96
+ if self.server_config.cors:
97
+ self.send_header("Access-Control-Allow-Origin", "*")
98
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
99
+ self.send_header("Access-Control-Allow-Headers", "*")
100
+
101
+ # Custom headers
102
+ for key, value in headers.items():
103
+ self.send_header(key, value)
104
+
105
+ # Default content-type
106
+ if "Content-Type" not in headers:
107
+ self.send_header("Content-Type", "application/json")
108
+
109
+ self.end_headers()
110
+
111
+ # Body
112
+ if isinstance(body, dict):
113
+ body = json.dumps(body)
114
+ self.wfile.write(body.encode("utf-8"))
115
+
116
+ def do_GET(self) -> None:
117
+ self._handle_request("GET")
118
+
119
+ def do_POST(self) -> None:
120
+ self._handle_request("POST")
121
+
122
+ def do_PUT(self) -> None:
123
+ self._handle_request("PUT")
124
+
125
+ def do_PATCH(self) -> None:
126
+ self._handle_request("PATCH")
127
+
128
+ def do_DELETE(self) -> None:
129
+ self._handle_request("DELETE")
130
+
131
+ def do_HEAD(self) -> None:
132
+ self._handle_request("HEAD")
133
+
134
+ def do_OPTIONS(self) -> None:
135
+ """Handle CORS preflight requests."""
136
+ self.send_response(200)
137
+ if self.server_config.cors:
138
+ self.send_header("Access-Control-Allow-Origin", "*")
139
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
140
+ self.send_header("Access-Control-Allow-Headers", "*")
141
+ self.end_headers()
142
+
143
+
144
+ class MockServer:
145
+ """Mock HTTP server for API testing."""
146
+
147
+ def __init__(self, config: MockServerConfig):
148
+ self.config = config
149
+ self._server: HTTPServer | None = None
150
+ self._thread: Thread | None = None
151
+
152
+ def start(self) -> None:
153
+ """Start the mock server in a background thread."""
154
+ handler_class = type("ConfiguredMockHandler", (MockHandler,), {"server_config": self.config})
155
+ self._server = HTTPServer((self.config.host, self.config.port), handler_class)
156
+ self._thread = Thread(target=self._server.serve_forever, daemon=True)
157
+ self._thread.start()
158
+
159
+ def stop(self) -> None:
160
+ """Stop the mock server."""
161
+ if self._server:
162
+ self._server.shutdown()
163
+ self._server = None
164
+ if self._thread:
165
+ self._thread.join(timeout=1)
166
+ self._thread = None
167
+
168
+ @property
169
+ def url(self) -> str:
170
+ """Get the mock server URL."""
171
+ return f"http://{self.config.host}:{self.config.port}"
172
+
173
+ def __enter__(self) -> "MockServer":
174
+ self.start()
175
+ return self
176
+
177
+ def __exit__(self, *args: Any) -> None:
178
+ self.stop()
179
+
180
+
181
+ def run_mock_server_blocking(config: MockServerConfig) -> None:
182
+ """Run the mock server in blocking mode (for CLI use)."""
183
+ handler_class = type("ConfiguredMockHandler", (MockHandler,), {"server_config": config})
184
+ server = HTTPServer((config.host, config.port), handler_class)
185
+ try:
186
+ server.serve_forever()
187
+ except KeyboardInterrupt:
188
+ pass
189
+ finally:
190
+ server.shutdown()