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/__init__.py
ADDED
api_forge/__main__.py
ADDED
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)
|
api_forge/mock_server.py
ADDED
|
@@ -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()
|