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 ADDED
@@ -0,0 +1,3 @@
1
+ """httptest - Local HTTP Test Server CLI."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ httptest = httptest.cli:main
@@ -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