httptest-cli 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.
- httptest/__init__.py +3 -0
- httptest/cli.py +651 -0
- httptest_cli-0.1.0.dist-info/METADATA +235 -0
- httptest_cli-0.1.0.dist-info/RECORD +8 -0
- httptest_cli-0.1.0.dist-info/WHEEL +5 -0
- httptest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- httptest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- httptest_cli-0.1.0.dist-info/top_level.txt +1 -0
httptest/__init__.py
ADDED
httptest/cli.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""httptest - Local HTTP Test Server CLI.
|
|
3
|
+
|
|
4
|
+
Spin up test servers for development: echo requests, serve files, mock APIs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
import mimetypes
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
16
|
+
from functools import partial
|
|
17
|
+
from urllib.parse import urlparse, parse_qs
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EchoHandler(BaseHTTPRequestHandler):
|
|
24
|
+
"""Echo back request details as JSON."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, record_file=None, cors=True, **kwargs):
|
|
27
|
+
self.record_file = record_file
|
|
28
|
+
self.cors = cors
|
|
29
|
+
super().__init__(*args, **kwargs)
|
|
30
|
+
|
|
31
|
+
def log_message(self, format, *args):
|
|
32
|
+
"""Suppress default logging."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def _set_cors_headers(self):
|
|
36
|
+
if self.cors:
|
|
37
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
38
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
|
39
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
40
|
+
|
|
41
|
+
def _read_body(self):
|
|
42
|
+
content_length = self.headers.get('Content-Length')
|
|
43
|
+
if content_length:
|
|
44
|
+
return self.rfile.read(int(content_length)).decode('utf-8', errors='replace')
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def _build_response(self, method):
|
|
48
|
+
parsed = urlparse(self.path)
|
|
49
|
+
body = self._read_body() if method in ('POST', 'PUT', 'PATCH') else None
|
|
50
|
+
|
|
51
|
+
# Try to parse JSON body
|
|
52
|
+
body_json = None
|
|
53
|
+
if body:
|
|
54
|
+
try:
|
|
55
|
+
body_json = json.loads(body)
|
|
56
|
+
except:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
response = {
|
|
60
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
61
|
+
"method": method,
|
|
62
|
+
"path": parsed.path,
|
|
63
|
+
"query": parse_qs(parsed.query),
|
|
64
|
+
"headers": dict(self.headers),
|
|
65
|
+
"body": body_json if body_json else body,
|
|
66
|
+
"client": {
|
|
67
|
+
"address": self.client_address[0],
|
|
68
|
+
"port": self.client_address[1]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Record if enabled
|
|
73
|
+
if self.record_file:
|
|
74
|
+
with open(self.record_file, 'a') as f:
|
|
75
|
+
f.write(json.dumps(response) + '\n')
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
def _send_json(self, data, status=200):
|
|
80
|
+
response = json.dumps(data, indent=2)
|
|
81
|
+
self.send_response(status)
|
|
82
|
+
self.send_header("Content-Type", "application/json")
|
|
83
|
+
self._set_cors_headers()
|
|
84
|
+
self.end_headers()
|
|
85
|
+
self.wfile.write(response.encode())
|
|
86
|
+
|
|
87
|
+
def do_OPTIONS(self):
|
|
88
|
+
self.send_response(200)
|
|
89
|
+
self._set_cors_headers()
|
|
90
|
+
self.end_headers()
|
|
91
|
+
|
|
92
|
+
def do_GET(self):
|
|
93
|
+
self._send_json(self._build_response("GET"))
|
|
94
|
+
|
|
95
|
+
def do_POST(self):
|
|
96
|
+
self._send_json(self._build_response("POST"))
|
|
97
|
+
|
|
98
|
+
def do_PUT(self):
|
|
99
|
+
self._send_json(self._build_response("PUT"))
|
|
100
|
+
|
|
101
|
+
def do_DELETE(self):
|
|
102
|
+
self._send_json(self._build_response("DELETE"))
|
|
103
|
+
|
|
104
|
+
def do_PATCH(self):
|
|
105
|
+
self._send_json(self._build_response("PATCH"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class StaticHandler(BaseHTTPRequestHandler):
|
|
109
|
+
"""Serve static files from a directory."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, *args, directory=".", cors=True, index="index.html", **kwargs):
|
|
112
|
+
self.directory = Path(directory).resolve()
|
|
113
|
+
self.cors = cors
|
|
114
|
+
self.index = index
|
|
115
|
+
super().__init__(*args, **kwargs)
|
|
116
|
+
|
|
117
|
+
def log_message(self, format, *args):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def _set_cors_headers(self):
|
|
121
|
+
if self.cors:
|
|
122
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
123
|
+
|
|
124
|
+
def do_GET(self):
|
|
125
|
+
parsed = urlparse(self.path)
|
|
126
|
+
path = parsed.path.lstrip('/')
|
|
127
|
+
|
|
128
|
+
if not path:
|
|
129
|
+
path = self.index
|
|
130
|
+
|
|
131
|
+
file_path = self.directory / path
|
|
132
|
+
|
|
133
|
+
# Security: prevent directory traversal
|
|
134
|
+
try:
|
|
135
|
+
file_path = file_path.resolve()
|
|
136
|
+
if not str(file_path).startswith(str(self.directory)):
|
|
137
|
+
self.send_error(403, "Forbidden")
|
|
138
|
+
return
|
|
139
|
+
except:
|
|
140
|
+
self.send_error(400, "Bad request")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Check if it's a directory
|
|
144
|
+
if file_path.is_dir():
|
|
145
|
+
index_path = file_path / self.index
|
|
146
|
+
if index_path.exists():
|
|
147
|
+
file_path = index_path
|
|
148
|
+
else:
|
|
149
|
+
# List directory
|
|
150
|
+
self._list_directory(file_path)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if not file_path.exists():
|
|
154
|
+
self.send_error(404, "Not found")
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Serve file
|
|
158
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
159
|
+
content_type = content_type or 'application/octet-stream'
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(file_path, 'rb') as f:
|
|
163
|
+
content = f.read()
|
|
164
|
+
|
|
165
|
+
self.send_response(200)
|
|
166
|
+
self.send_header("Content-Type", content_type)
|
|
167
|
+
self.send_header("Content-Length", len(content))
|
|
168
|
+
self._set_cors_headers()
|
|
169
|
+
self.end_headers()
|
|
170
|
+
self.wfile.write(content)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
self.send_error(500, str(e))
|
|
173
|
+
|
|
174
|
+
def _list_directory(self, path):
|
|
175
|
+
"""Generate directory listing."""
|
|
176
|
+
try:
|
|
177
|
+
entries = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
178
|
+
except OSError:
|
|
179
|
+
self.send_error(403, "Cannot list directory")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
html = ['<!DOCTYPE html><html><head><title>Directory listing</title>',
|
|
183
|
+
'<style>body{font-family:monospace;padding:20px}',
|
|
184
|
+
'a{text-decoration:none}a:hover{text-decoration:underline}',
|
|
185
|
+
'.dir{color:#0066cc}.file{color:#333}</style></head>',
|
|
186
|
+
'<body><h1>Directory listing</h1><ul>']
|
|
187
|
+
|
|
188
|
+
rel_path = path.relative_to(self.directory)
|
|
189
|
+
if str(rel_path) != '.':
|
|
190
|
+
html.append('<li><a href="../" class="dir">../</a></li>')
|
|
191
|
+
|
|
192
|
+
for entry in entries:
|
|
193
|
+
name = entry.name
|
|
194
|
+
if entry.is_dir():
|
|
195
|
+
html.append(f'<li><a href="{name}/" class="dir">{name}/</a></li>')
|
|
196
|
+
else:
|
|
197
|
+
size = entry.stat().st_size
|
|
198
|
+
html.append(f'<li><a href="{name}" class="file">{name}</a> ({size} bytes)</li>')
|
|
199
|
+
|
|
200
|
+
html.append('</ul></body></html>')
|
|
201
|
+
|
|
202
|
+
content = '\n'.join(html).encode()
|
|
203
|
+
self.send_response(200)
|
|
204
|
+
self.send_header("Content-Type", "text/html")
|
|
205
|
+
self.send_header("Content-Length", len(content))
|
|
206
|
+
self._set_cors_headers()
|
|
207
|
+
self.end_headers()
|
|
208
|
+
self.wfile.write(content)
|
|
209
|
+
|
|
210
|
+
def do_OPTIONS(self):
|
|
211
|
+
self.send_response(200)
|
|
212
|
+
self._set_cors_headers()
|
|
213
|
+
self.end_headers()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class MockHandler(BaseHTTPRequestHandler):
|
|
217
|
+
"""Serve mock API responses from a JSON spec file."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, *args, spec=None, cors=True, **kwargs):
|
|
220
|
+
self.spec = spec or {}
|
|
221
|
+
self.cors = cors
|
|
222
|
+
super().__init__(*args, **kwargs)
|
|
223
|
+
|
|
224
|
+
def log_message(self, format, *args):
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def _set_cors_headers(self):
|
|
228
|
+
if self.cors:
|
|
229
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
230
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
|
231
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
232
|
+
|
|
233
|
+
def _find_route(self, method, path):
|
|
234
|
+
"""Find matching route in spec."""
|
|
235
|
+
parsed = urlparse(path)
|
|
236
|
+
path_only = parsed.path
|
|
237
|
+
|
|
238
|
+
# Try exact match first
|
|
239
|
+
key = f"{method} {path_only}"
|
|
240
|
+
if key in self.spec:
|
|
241
|
+
return self.spec[key]
|
|
242
|
+
|
|
243
|
+
# Try method-agnostic match
|
|
244
|
+
if path_only in self.spec:
|
|
245
|
+
return self.spec[path_only]
|
|
246
|
+
|
|
247
|
+
# Try wildcard routes (simple pattern matching)
|
|
248
|
+
for route_key, route_spec in self.spec.items():
|
|
249
|
+
parts = route_key.split(' ', 1)
|
|
250
|
+
if len(parts) == 2:
|
|
251
|
+
route_method, route_path = parts
|
|
252
|
+
else:
|
|
253
|
+
route_method = None
|
|
254
|
+
route_path = parts[0]
|
|
255
|
+
|
|
256
|
+
if route_method and route_method != method:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# Simple wildcard matching
|
|
260
|
+
if '*' in route_path:
|
|
261
|
+
import fnmatch
|
|
262
|
+
if fnmatch.fnmatch(path_only, route_path):
|
|
263
|
+
return route_spec
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _handle_request(self, method):
|
|
268
|
+
route = self._find_route(method, self.path)
|
|
269
|
+
|
|
270
|
+
if route is None:
|
|
271
|
+
self.send_response(404)
|
|
272
|
+
self.send_header("Content-Type", "application/json")
|
|
273
|
+
self._set_cors_headers()
|
|
274
|
+
self.end_headers()
|
|
275
|
+
self.wfile.write(json.dumps({"error": "Route not found", "path": self.path}).encode())
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Handle different route spec formats
|
|
279
|
+
if isinstance(route, dict):
|
|
280
|
+
status = route.get('status', 200)
|
|
281
|
+
headers = route.get('headers', {})
|
|
282
|
+
body = route.get('body', route.get('response', {}))
|
|
283
|
+
delay = route.get('delay', 0)
|
|
284
|
+
else:
|
|
285
|
+
status = 200
|
|
286
|
+
headers = {}
|
|
287
|
+
body = route
|
|
288
|
+
delay = 0
|
|
289
|
+
|
|
290
|
+
# Add delay if specified
|
|
291
|
+
if delay > 0:
|
|
292
|
+
time.sleep(delay / 1000.0) # delay in ms
|
|
293
|
+
|
|
294
|
+
# Send response
|
|
295
|
+
self.send_response(status)
|
|
296
|
+
|
|
297
|
+
# Default to JSON
|
|
298
|
+
if 'Content-Type' not in headers:
|
|
299
|
+
headers['Content-Type'] = 'application/json'
|
|
300
|
+
|
|
301
|
+
for key, value in headers.items():
|
|
302
|
+
self.send_header(key, value)
|
|
303
|
+
self._set_cors_headers()
|
|
304
|
+
self.end_headers()
|
|
305
|
+
|
|
306
|
+
if isinstance(body, (dict, list)):
|
|
307
|
+
self.wfile.write(json.dumps(body, indent=2).encode())
|
|
308
|
+
else:
|
|
309
|
+
self.wfile.write(str(body).encode())
|
|
310
|
+
|
|
311
|
+
def do_OPTIONS(self):
|
|
312
|
+
self.send_response(200)
|
|
313
|
+
self._set_cors_headers()
|
|
314
|
+
self.end_headers()
|
|
315
|
+
|
|
316
|
+
def do_GET(self):
|
|
317
|
+
self._handle_request("GET")
|
|
318
|
+
|
|
319
|
+
def do_POST(self):
|
|
320
|
+
self._handle_request("POST")
|
|
321
|
+
|
|
322
|
+
def do_PUT(self):
|
|
323
|
+
self._handle_request("PUT")
|
|
324
|
+
|
|
325
|
+
def do_DELETE(self):
|
|
326
|
+
self._handle_request("DELETE")
|
|
327
|
+
|
|
328
|
+
def do_PATCH(self):
|
|
329
|
+
self._handle_request("PATCH")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def create_handler(handler_class, **kwargs):
|
|
333
|
+
"""Create a handler class with custom attributes."""
|
|
334
|
+
return partial(handler_class, **kwargs)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run_server(handler, port, host='0.0.0.0', quiet=False):
|
|
338
|
+
"""Run the HTTP server."""
|
|
339
|
+
server = HTTPServer((host, port), handler)
|
|
340
|
+
if not quiet:
|
|
341
|
+
click.echo(f"Server running at http://{host}:{port}")
|
|
342
|
+
click.echo("Press Ctrl+C to stop")
|
|
343
|
+
try:
|
|
344
|
+
server.serve_forever()
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
if not quiet:
|
|
347
|
+
click.echo("\nServer stopped")
|
|
348
|
+
server.shutdown()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@click.group()
|
|
352
|
+
@click.version_option(__version__)
|
|
353
|
+
def cli():
|
|
354
|
+
"""httptest - Local HTTP Test Server CLI.
|
|
355
|
+
|
|
356
|
+
Spin up test servers for development: echo requests, serve files, mock APIs.
|
|
357
|
+
"""
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@cli.command()
|
|
362
|
+
@click.option('-p', '--port', default=8080, help='Port to listen on')
|
|
363
|
+
@click.option('-H', '--host', default='0.0.0.0', help='Host to bind to')
|
|
364
|
+
@click.option('--no-cors', is_flag=True, help='Disable CORS headers')
|
|
365
|
+
@click.option('-r', '--record', type=click.Path(), help='Record requests to JSONL file')
|
|
366
|
+
@click.option('-q', '--quiet', is_flag=True, help='Suppress startup message')
|
|
367
|
+
def echo(port, host, no_cors, record, quiet):
|
|
368
|
+
"""Echo all requests back as JSON.
|
|
369
|
+
|
|
370
|
+
Returns request details including method, path, headers, and body.
|
|
371
|
+
Useful for debugging webhooks or API clients.
|
|
372
|
+
|
|
373
|
+
Examples:
|
|
374
|
+
|
|
375
|
+
httptest echo # Start on port 8080
|
|
376
|
+
|
|
377
|
+
httptest echo -p 3000 # Custom port
|
|
378
|
+
|
|
379
|
+
httptest echo -r requests.jsonl # Record all requests
|
|
380
|
+
"""
|
|
381
|
+
handler = create_handler(EchoHandler, record_file=record, cors=not no_cors)
|
|
382
|
+
run_server(handler, port, host, quiet)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@cli.command()
|
|
386
|
+
@click.argument('directory', default='.', type=click.Path(exists=True))
|
|
387
|
+
@click.option('-p', '--port', default=8080, help='Port to listen on')
|
|
388
|
+
@click.option('-H', '--host', default='0.0.0.0', help='Host to bind to')
|
|
389
|
+
@click.option('--no-cors', is_flag=True, help='Disable CORS headers')
|
|
390
|
+
@click.option('-i', '--index', default='index.html', help='Index file name')
|
|
391
|
+
@click.option('-q', '--quiet', is_flag=True, help='Suppress startup message')
|
|
392
|
+
def static(directory, port, host, no_cors, index, quiet):
|
|
393
|
+
"""Serve static files from a directory.
|
|
394
|
+
|
|
395
|
+
Automatically serves index.html for directories.
|
|
396
|
+
Shows directory listing if no index file.
|
|
397
|
+
|
|
398
|
+
Examples:
|
|
399
|
+
|
|
400
|
+
httptest static ./public # Serve ./public
|
|
401
|
+
|
|
402
|
+
httptest static . -p 3000 # Current dir on port 3000
|
|
403
|
+
|
|
404
|
+
httptest static dist --index main.html
|
|
405
|
+
"""
|
|
406
|
+
handler = create_handler(StaticHandler, directory=directory, cors=not no_cors, index=index)
|
|
407
|
+
if not quiet:
|
|
408
|
+
click.echo(f"Serving files from: {Path(directory).resolve()}")
|
|
409
|
+
run_server(handler, port, host, quiet)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@cli.command()
|
|
413
|
+
@click.argument('spec_file', type=click.Path(exists=True))
|
|
414
|
+
@click.option('-p', '--port', default=8080, help='Port to listen on')
|
|
415
|
+
@click.option('-H', '--host', default='0.0.0.0', help='Host to bind to')
|
|
416
|
+
@click.option('--no-cors', is_flag=True, help='Disable CORS headers')
|
|
417
|
+
@click.option('-q', '--quiet', is_flag=True, help='Suppress startup message')
|
|
418
|
+
def mock(spec_file, port, host, no_cors, quiet):
|
|
419
|
+
"""Serve mock API responses from a JSON spec file.
|
|
420
|
+
|
|
421
|
+
Spec file format:
|
|
422
|
+
|
|
423
|
+
\b
|
|
424
|
+
{
|
|
425
|
+
"GET /users": {"body": [{"id": 1, "name": "Alice"}]},
|
|
426
|
+
"POST /users": {"status": 201, "body": {"id": 2}},
|
|
427
|
+
"/health": {"body": {"status": "ok"}},
|
|
428
|
+
"GET /slow": {"body": "ok", "delay": 1000}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
Route keys can be:
|
|
432
|
+
- "METHOD /path" - match specific method
|
|
433
|
+
- "/path" - match any method
|
|
434
|
+
- "/users/*" - wildcard matching
|
|
435
|
+
|
|
436
|
+
Response spec:
|
|
437
|
+
- body/response: response body (dict becomes JSON)
|
|
438
|
+
- status: HTTP status code (default 200)
|
|
439
|
+
- headers: custom headers dict
|
|
440
|
+
- delay: response delay in ms
|
|
441
|
+
|
|
442
|
+
Examples:
|
|
443
|
+
|
|
444
|
+
httptest mock api.json # Serve mock API
|
|
445
|
+
|
|
446
|
+
httptest mock spec.json -p 3000 # Custom port
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
with open(spec_file) as f:
|
|
450
|
+
spec = json.load(f)
|
|
451
|
+
except json.JSONDecodeError as e:
|
|
452
|
+
click.echo(f"Error parsing spec file: {e}", err=True)
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
handler = create_handler(MockHandler, spec=spec, cors=not no_cors)
|
|
456
|
+
if not quiet:
|
|
457
|
+
click.echo(f"Mock API loaded from: {spec_file}")
|
|
458
|
+
click.echo(f"Routes: {len(spec)}")
|
|
459
|
+
run_server(handler, port, host, quiet)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@cli.command()
|
|
463
|
+
@click.option('-p', '--port', default=8080, help='Port to listen on')
|
|
464
|
+
@click.option('-H', '--host', default='0.0.0.0', help='Host to bind to')
|
|
465
|
+
@click.option('-o', '--out', default='requests.jsonl', help='Output file')
|
|
466
|
+
@click.option('--no-cors', is_flag=True, help='Disable CORS headers')
|
|
467
|
+
@click.option('-q', '--quiet', is_flag=True, help='Suppress startup message')
|
|
468
|
+
def record(port, host, out, no_cors, quiet):
|
|
469
|
+
"""Record incoming requests to a JSONL file.
|
|
470
|
+
|
|
471
|
+
Echoes requests like 'echo' command but saves them to a file.
|
|
472
|
+
Each line is a JSON object with request details.
|
|
473
|
+
|
|
474
|
+
Examples:
|
|
475
|
+
|
|
476
|
+
httptest record # Record to requests.jsonl
|
|
477
|
+
|
|
478
|
+
httptest record -o webhooks.jsonl # Custom output file
|
|
479
|
+
"""
|
|
480
|
+
handler = create_handler(EchoHandler, record_file=out, cors=not no_cors)
|
|
481
|
+
if not quiet:
|
|
482
|
+
click.echo(f"Recording requests to: {out}")
|
|
483
|
+
run_server(handler, port, host, quiet)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
@cli.command()
|
|
487
|
+
@click.argument('input_file', type=click.Path(exists=True))
|
|
488
|
+
@click.option('-t', '--target', required=True, help='Target URL base (e.g., http://localhost:3000)')
|
|
489
|
+
@click.option('-d', '--delay', default=0, help='Delay between requests in ms')
|
|
490
|
+
@click.option('--json', 'json_output', is_flag=True, help='Output as JSON')
|
|
491
|
+
def replay(input_file, target, delay, json_output):
|
|
492
|
+
"""Replay recorded requests to a target server.
|
|
493
|
+
|
|
494
|
+
Reads a JSONL file of recorded requests and replays them
|
|
495
|
+
against a target URL.
|
|
496
|
+
|
|
497
|
+
Examples:
|
|
498
|
+
|
|
499
|
+
httptest replay requests.jsonl -t http://localhost:3000
|
|
500
|
+
|
|
501
|
+
httptest replay webhooks.jsonl -t https://staging.api.com -d 100
|
|
502
|
+
"""
|
|
503
|
+
import urllib.request
|
|
504
|
+
import urllib.error
|
|
505
|
+
|
|
506
|
+
results = []
|
|
507
|
+
|
|
508
|
+
with open(input_file) as f:
|
|
509
|
+
for line_num, line in enumerate(f, 1):
|
|
510
|
+
line = line.strip()
|
|
511
|
+
if not line:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
req_data = json.loads(line)
|
|
516
|
+
except json.JSONDecodeError as e:
|
|
517
|
+
click.echo(f"Line {line_num}: Invalid JSON - {e}", err=True)
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
method = req_data.get('method', 'GET')
|
|
521
|
+
path = req_data.get('path', '/')
|
|
522
|
+
body = req_data.get('body')
|
|
523
|
+
headers = req_data.get('headers', {})
|
|
524
|
+
|
|
525
|
+
url = target.rstrip('/') + path
|
|
526
|
+
|
|
527
|
+
# Build request
|
|
528
|
+
data = None
|
|
529
|
+
if body:
|
|
530
|
+
if isinstance(body, (dict, list)):
|
|
531
|
+
data = json.dumps(body).encode()
|
|
532
|
+
else:
|
|
533
|
+
data = str(body).encode()
|
|
534
|
+
|
|
535
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
536
|
+
|
|
537
|
+
# Add headers (skip host, content-length)
|
|
538
|
+
for key, value in headers.items():
|
|
539
|
+
if key.lower() not in ('host', 'content-length'):
|
|
540
|
+
req.add_header(key, value)
|
|
541
|
+
|
|
542
|
+
# Make request
|
|
543
|
+
try:
|
|
544
|
+
start = time.time()
|
|
545
|
+
response = urllib.request.urlopen(req, timeout=30)
|
|
546
|
+
duration = (time.time() - start) * 1000
|
|
547
|
+
status = response.status
|
|
548
|
+
result = {
|
|
549
|
+
"line": line_num,
|
|
550
|
+
"method": method,
|
|
551
|
+
"path": path,
|
|
552
|
+
"status": status,
|
|
553
|
+
"duration_ms": round(duration, 2),
|
|
554
|
+
"success": True
|
|
555
|
+
}
|
|
556
|
+
except urllib.error.HTTPError as e:
|
|
557
|
+
duration = 0
|
|
558
|
+
result = {
|
|
559
|
+
"line": line_num,
|
|
560
|
+
"method": method,
|
|
561
|
+
"path": path,
|
|
562
|
+
"status": e.code,
|
|
563
|
+
"error": str(e.reason),
|
|
564
|
+
"success": False
|
|
565
|
+
}
|
|
566
|
+
except Exception as e:
|
|
567
|
+
result = {
|
|
568
|
+
"line": line_num,
|
|
569
|
+
"method": method,
|
|
570
|
+
"path": path,
|
|
571
|
+
"error": str(e),
|
|
572
|
+
"success": False
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
results.append(result)
|
|
576
|
+
|
|
577
|
+
if not json_output:
|
|
578
|
+
status_str = result.get('status', 'ERR')
|
|
579
|
+
icon = "✓" if result.get('success') else "✗"
|
|
580
|
+
click.echo(f"{icon} {method} {path} → {status_str}")
|
|
581
|
+
|
|
582
|
+
if delay > 0:
|
|
583
|
+
time.sleep(delay / 1000.0)
|
|
584
|
+
|
|
585
|
+
if json_output:
|
|
586
|
+
click.echo(json.dumps(results, indent=2))
|
|
587
|
+
else:
|
|
588
|
+
success = sum(1 for r in results if r.get('success'))
|
|
589
|
+
click.echo(f"\nReplayed {len(results)} requests: {success} succeeded, {len(results) - success} failed")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@cli.command()
|
|
593
|
+
@click.option('-p', '--port', default=8080, help='Port to listen on')
|
|
594
|
+
@click.option('-H', '--host', default='0.0.0.0', help='Host to bind to')
|
|
595
|
+
@click.option('--status', default=200, help='Response status code')
|
|
596
|
+
@click.option('--body', default='ok', help='Response body')
|
|
597
|
+
@click.option('--content-type', default='text/plain', help='Content-Type header')
|
|
598
|
+
@click.option('-q', '--quiet', is_flag=True, help='Suppress startup message')
|
|
599
|
+
def fixed(port, host, status, body, content_type, quiet):
|
|
600
|
+
"""Return a fixed response for all requests.
|
|
601
|
+
|
|
602
|
+
Simple server that returns the same response regardless of request.
|
|
603
|
+
Useful for health checks or simple testing.
|
|
604
|
+
|
|
605
|
+
Examples:
|
|
606
|
+
|
|
607
|
+
httptest fixed # Returns "ok" with 200
|
|
608
|
+
|
|
609
|
+
httptest fixed --status 503 --body "maintenance"
|
|
610
|
+
|
|
611
|
+
httptest fixed --body '{"status":"healthy"}' --content-type application/json
|
|
612
|
+
"""
|
|
613
|
+
class FixedHandler(BaseHTTPRequestHandler):
|
|
614
|
+
def log_message(self, format, *args):
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
def _respond(self):
|
|
618
|
+
self.send_response(status)
|
|
619
|
+
self.send_header("Content-Type", content_type)
|
|
620
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
621
|
+
self.end_headers()
|
|
622
|
+
self.wfile.write(body.encode())
|
|
623
|
+
|
|
624
|
+
def do_GET(self):
|
|
625
|
+
self._respond()
|
|
626
|
+
|
|
627
|
+
def do_POST(self):
|
|
628
|
+
self._respond()
|
|
629
|
+
|
|
630
|
+
def do_PUT(self):
|
|
631
|
+
self._respond()
|
|
632
|
+
|
|
633
|
+
def do_DELETE(self):
|
|
634
|
+
self._respond()
|
|
635
|
+
|
|
636
|
+
def do_OPTIONS(self):
|
|
637
|
+
self.send_response(200)
|
|
638
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
639
|
+
self.send_header("Access-Control-Allow-Methods", "*")
|
|
640
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
641
|
+
self.end_headers()
|
|
642
|
+
|
|
643
|
+
run_server(FixedHandler, port, host, quiet)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def main():
|
|
647
|
+
cli()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
if __name__ == "__main__":
|
|
651
|
+
main()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httptest-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local HTTP test server CLI - echo, static files, mock APIs
|
|
5
|
+
Author-email: Marcus <marcus.builds.things@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/httptest
|
|
8
|
+
Project-URL: Repository, https://github.com/marcusbuildsthings-droid/httptest
|
|
9
|
+
Project-URL: Issues, https://github.com/marcusbuildsthings-droid/httptest/issues
|
|
10
|
+
Keywords: http,server,testing,mock,api,development,cli
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: click>=8.0
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# httptest
|
|
32
|
+
|
|
33
|
+
Local HTTP test server CLI for development. Echo requests, serve static files, or mock APIs.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install httptest-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Echo all requests back as JSON
|
|
45
|
+
httptest echo
|
|
46
|
+
|
|
47
|
+
# Serve static files
|
|
48
|
+
httptest static ./public
|
|
49
|
+
|
|
50
|
+
# Mock API from JSON spec
|
|
51
|
+
httptest mock api.json
|
|
52
|
+
|
|
53
|
+
# Record requests to file
|
|
54
|
+
httptest record -o requests.jsonl
|
|
55
|
+
|
|
56
|
+
# Replay recorded requests
|
|
57
|
+
httptest replay requests.jsonl -t http://localhost:3000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
### echo
|
|
63
|
+
|
|
64
|
+
Echo all incoming requests back as JSON. Useful for debugging webhooks or API clients.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
httptest echo # Start on port 8080
|
|
68
|
+
httptest echo -p 3000 # Custom port
|
|
69
|
+
httptest echo -r requests.jsonl # Record all requests
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Response format:
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"timestamp": "2026-02-04T12:00:00.000Z",
|
|
76
|
+
"method": "POST",
|
|
77
|
+
"path": "/webhook",
|
|
78
|
+
"query": {"foo": ["bar"]},
|
|
79
|
+
"headers": {"Content-Type": "application/json", ...},
|
|
80
|
+
"body": {"event": "user.created"},
|
|
81
|
+
"client": {"address": "127.0.0.1", "port": 54321}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### static
|
|
86
|
+
|
|
87
|
+
Serve static files from a directory with automatic index.html and directory listings.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
httptest static ./public # Serve ./public directory
|
|
91
|
+
httptest static . -p 3000 # Current dir on port 3000
|
|
92
|
+
httptest static dist --index main.html # Custom index file
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### mock
|
|
96
|
+
|
|
97
|
+
Serve mock API responses from a JSON spec file.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
httptest mock api.json
|
|
101
|
+
httptest mock spec.json -p 3000
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Spec file format:**
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"GET /users": {
|
|
109
|
+
"body": [
|
|
110
|
+
{"id": 1, "name": "Alice"},
|
|
111
|
+
{"id": 2, "name": "Bob"}
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
"POST /users": {
|
|
115
|
+
"status": 201,
|
|
116
|
+
"body": {"id": 3, "message": "Created"}
|
|
117
|
+
},
|
|
118
|
+
"GET /slow": {
|
|
119
|
+
"body": {"status": "ok"},
|
|
120
|
+
"delay": 1000
|
|
121
|
+
},
|
|
122
|
+
"/health": {
|
|
123
|
+
"body": {"status": "healthy"}
|
|
124
|
+
},
|
|
125
|
+
"GET /users/*": {
|
|
126
|
+
"body": {"id": 1, "name": "User"}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Route keys:
|
|
132
|
+
- `"METHOD /path"` - match specific HTTP method
|
|
133
|
+
- `"/path"` - match any method
|
|
134
|
+
- `"/path/*"` - wildcard matching
|
|
135
|
+
|
|
136
|
+
Response options:
|
|
137
|
+
- `body` or `response` - response body (objects become JSON)
|
|
138
|
+
- `status` - HTTP status code (default: 200)
|
|
139
|
+
- `headers` - custom response headers
|
|
140
|
+
- `delay` - response delay in milliseconds
|
|
141
|
+
|
|
142
|
+
### record
|
|
143
|
+
|
|
144
|
+
Record all incoming requests to a JSONL file.
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
httptest record # Record to requests.jsonl
|
|
148
|
+
httptest record -o webhooks.jsonl
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### replay
|
|
152
|
+
|
|
153
|
+
Replay recorded requests against a target server.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
httptest replay requests.jsonl -t http://localhost:3000
|
|
157
|
+
httptest replay webhooks.jsonl -t https://staging.api.com -d 100
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Options:
|
|
161
|
+
- `-t, --target` - Target URL base (required)
|
|
162
|
+
- `-d, --delay` - Delay between requests in ms
|
|
163
|
+
- `--json` - Output results as JSON
|
|
164
|
+
|
|
165
|
+
### fixed
|
|
166
|
+
|
|
167
|
+
Return a fixed response for all requests.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
httptest fixed # Returns "ok" with 200
|
|
171
|
+
httptest fixed --status 503 --body "maintenance"
|
|
172
|
+
httptest fixed --body '{"status":"healthy"}' --content-type application/json
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Common Options
|
|
176
|
+
|
|
177
|
+
All server commands support:
|
|
178
|
+
- `-p, --port` - Port to listen on (default: 8080)
|
|
179
|
+
- `-H, --host` - Host to bind to (default: 0.0.0.0)
|
|
180
|
+
- `--no-cors` - Disable CORS headers
|
|
181
|
+
- `-q, --quiet` - Suppress startup message
|
|
182
|
+
|
|
183
|
+
## Use Cases
|
|
184
|
+
|
|
185
|
+
### Webhook Development
|
|
186
|
+
|
|
187
|
+
Test webhooks locally by echoing requests:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
httptest echo -p 9999 -r webhooks.jsonl
|
|
191
|
+
# Configure your service to send webhooks to http://localhost:9999
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### API Prototyping
|
|
195
|
+
|
|
196
|
+
Mock an API before it's built:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# api.json
|
|
200
|
+
{
|
|
201
|
+
"GET /api/users": {"body": [{"id": 1, "name": "Test"}]},
|
|
202
|
+
"POST /api/users": {"status": 201, "body": {"id": 2}},
|
|
203
|
+
"GET /api/users/*": {"body": {"id": 1, "name": "User"}}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
httptest mock api.json -p 3001
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Frontend Development
|
|
210
|
+
|
|
211
|
+
Serve your frontend build:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
httptest static ./dist -p 8000
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Load Testing Prep
|
|
218
|
+
|
|
219
|
+
Record production traffic, then replay against staging:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
# Record
|
|
223
|
+
httptest record -p 8080 -o traffic.jsonl
|
|
224
|
+
|
|
225
|
+
# Replay
|
|
226
|
+
httptest replay traffic.jsonl -t http://staging.example.com
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## For AI Agents
|
|
230
|
+
|
|
231
|
+
See [SKILL.md](SKILL.md) for agent-optimized documentation.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
httptest/__init__.py,sha256=Yrn3rTLYk4eIqH_zw0gH3HNoXMZeurk_NV-A8UD5jYw,68
|
|
2
|
+
httptest/cli.py,sha256=ibUKnPZv3_r71buujYeAbkAJNsykV5PqCB1S5UYE5gU,21792
|
|
3
|
+
httptest_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
|
|
4
|
+
httptest_cli-0.1.0.dist-info/METADATA,sha256=vD1RNz9wWu-sO_dvwYoMItr18f-egzPIXz3bGXTQRfo,5415
|
|
5
|
+
httptest_cli-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
httptest_cli-0.1.0.dist-info/entry_points.txt,sha256=DC4Nu8r8MHw4sAKsxd0RgTR4Pjeq7xDptJSxX7eDjfU,47
|
|
7
|
+
httptest_cli-0.1.0.dist-info/top_level.txt,sha256=cOPvre9yD0Pf88K6LRy5EtD-MI3bYJS-3UROswEQ_cc,9
|
|
8
|
+
httptest_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httptest
|