webhook-replay 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.
@@ -0,0 +1,29 @@
1
+ """webhook-replay: capture, inspect and re-fire webhooks locally.
2
+
3
+ A zero-dependency developer tool for debugging webhook integrations without
4
+ re-triggering the real upstream event.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .diff import diff_records
11
+ from .export import to_curl
12
+ from .redact import is_sensitive, redact_headers
13
+ from .replay import ReplayResult, replay
14
+ from .server import create_server, make_handler
15
+ from .storage import Storage, WebhookRecord
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "Storage",
20
+ "WebhookRecord",
21
+ "create_server",
22
+ "make_handler",
23
+ "replay",
24
+ "ReplayResult",
25
+ "to_curl",
26
+ "diff_records",
27
+ "redact_headers",
28
+ "is_sensitive",
29
+ ]
@@ -0,0 +1,9 @@
1
+ """Enable ``python -m webhook_replay``."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1,60 @@
1
+ """Tiny ANSI color helper (stdlib only).
2
+
3
+ Coloring is disabled automatically when stdout is not a TTY or when the
4
+ ``NO_COLOR`` environment variable is set (https://no-color.org/).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+
11
+ _ENABLED = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
12
+
13
+ _CODES = {
14
+ "reset": "0",
15
+ "bold": "1",
16
+ "dim": "2",
17
+ "red": "31",
18
+ "green": "32",
19
+ "yellow": "33",
20
+ "blue": "34",
21
+ "magenta": "35",
22
+ "cyan": "36",
23
+ "gray": "90",
24
+ }
25
+
26
+ # HTTP method -> color, for quick visual scanning of a request list.
27
+ _METHOD_COLORS = {
28
+ "GET": "green",
29
+ "POST": "yellow",
30
+ "PUT": "blue",
31
+ "PATCH": "magenta",
32
+ "DELETE": "red",
33
+ "HEAD": "gray",
34
+ "OPTIONS": "gray",
35
+ }
36
+
37
+
38
+ def set_enabled(value: bool) -> None:
39
+ """Force-enable or force-disable coloring (used by ``--no-color``)."""
40
+ global _ENABLED
41
+ _ENABLED = bool(value)
42
+
43
+
44
+ def enabled() -> bool:
45
+ return _ENABLED
46
+
47
+
48
+ def paint(text: str, *styles: str) -> str:
49
+ """Wrap ``text`` in the given ANSI styles, unless coloring is disabled."""
50
+ if not _ENABLED or not styles:
51
+ return text
52
+ seq = "".join(f"\x1b[{_CODES[s]}m" for s in styles if s in _CODES)
53
+ if not seq:
54
+ return text
55
+ return f"{seq}{text}\x1b[0m"
56
+
57
+
58
+ def method(name: str) -> str:
59
+ """Colorize an HTTP method name."""
60
+ return paint(name, _METHOD_COLORS.get(name.upper(), "cyan"), "bold")
webhook_replay/cli.py ADDED
@@ -0,0 +1,487 @@
1
+ """Command-line interface for webhook-replay."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import sys
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ from . import __version__, _color
11
+ from .diff import diff_records
12
+ from .export import to_curl
13
+ from .redact import has_sensitive, redact_headers
14
+ from .replay import replay
15
+ from .server import (
16
+ DEFAULT_MAX_BODY_BYTES,
17
+ DEFAULT_MAX_CAPTURES,
18
+ DEFAULT_READ_TIMEOUT,
19
+ create_server,
20
+ )
21
+ from .storage import DEFAULT_DB, Header, Storage, WebhookRecord
22
+
23
+ _SINCE_RE = re.compile(r"^(\d+)([smhd])$", re.IGNORECASE)
24
+ _SINCE_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}
25
+
26
+ _SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*([kmg]?i?b?)$", re.IGNORECASE)
27
+ _SIZE_UNITS = {
28
+ "": 1,
29
+ "b": 1,
30
+ "k": 1000, "kb": 1000, "ki": 1024, "kib": 1024,
31
+ "m": 1000 ** 2, "mb": 1000 ** 2, "mi": 1024 ** 2, "mib": 1024 ** 2,
32
+ "g": 1000 ** 3, "gb": 1000 ** 3, "gi": 1024 ** 3, "gib": 1024 ** 3,
33
+ }
34
+
35
+ _REDACTION_HINT = (
36
+ "some header values are masked; re-run with --show-secrets to see them"
37
+ )
38
+
39
+
40
+ def parse_since(value: str | None) -> str | None:
41
+ """Turn ``10m`` / ``2h`` / ``1d`` or an ISO timestamp into a UTC ISO cutoff.
42
+
43
+ Units are case-insensitive (``7D`` is seven days; there is no month unit,
44
+ so ``10M`` is ten minutes). A naive ISO timestamp is taken as UTC. Anything
45
+ else raises :class:`argparse.ArgumentTypeError`: the store compares
46
+ timestamps as strings, so an unparsed value such as ``yesterday`` would
47
+ otherwise become a cutoff that matches -- or deletes -- every capture.
48
+ """
49
+ if not value:
50
+ return None
51
+ text = value.strip()
52
+ match = _SINCE_RE.match(text)
53
+ if match:
54
+ amount, unit = int(match.group(1)), match.group(2).lower()
55
+ cutoff = datetime.now(timezone.utc) - timedelta(**{_SINCE_UNITS[unit]: amount})
56
+ return cutoff.isoformat(timespec="milliseconds")
57
+ if text.endswith(("Z", "z")):
58
+ # datetime.fromisoformat only accepts a trailing Z from Python 3.11 on.
59
+ text = text[:-1] + "+00:00"
60
+ try:
61
+ parsed = datetime.fromisoformat(text)
62
+ except ValueError:
63
+ raise argparse.ArgumentTypeError(
64
+ f"expected an age like 7d, 12h, 10m or an ISO timestamp, got {value!r}"
65
+ ) from None
66
+ if parsed.tzinfo is None:
67
+ parsed = parsed.replace(tzinfo=timezone.utc)
68
+ return parsed.astimezone(timezone.utc).isoformat(timespec="milliseconds")
69
+
70
+
71
+ def parse_positive_int(value: str) -> int:
72
+ """An integer of at least 1, for counts such as ``--times``."""
73
+ try:
74
+ number = int(value)
75
+ except ValueError:
76
+ raise argparse.ArgumentTypeError(f"expected an integer, got {value!r}") from None
77
+ if number < 1:
78
+ raise argparse.ArgumentTypeError(f"must be >= 1, got {number}")
79
+ return number
80
+
81
+
82
+ def parse_keep(value: str) -> int:
83
+ """``--keep N``: at least 1, because 0 would silently keep everything."""
84
+ try:
85
+ return parse_positive_int(value)
86
+ except argparse.ArgumentTypeError:
87
+ raise argparse.ArgumentTypeError(
88
+ "--keep must be >= 1; use `clear` to delete everything"
89
+ ) from None
90
+
91
+
92
+ def parse_size(value: str) -> int:
93
+ """Turn ``1048576`` / ``512KB`` / ``1MiB`` into a byte count."""
94
+ match = _SIZE_RE.match(str(value).strip())
95
+ if not match:
96
+ raise argparse.ArgumentTypeError(
97
+ f"size must be bytes or a value like 512KB / 1MiB, got {value!r}"
98
+ )
99
+ amount, unit = float(match.group(1)), match.group(2).lower()
100
+ if unit not in _SIZE_UNITS:
101
+ raise argparse.ArgumentTypeError(f"unknown size unit in {value!r}")
102
+ return int(amount * _SIZE_UNITS[unit])
103
+
104
+
105
+ def parse_header(raw: str) -> Header:
106
+ if ":" not in raw:
107
+ raise argparse.ArgumentTypeError(
108
+ f"header must be in 'Name: value' form, got {raw!r}"
109
+ )
110
+ key, value = raw.split(":", 1)
111
+ return key.strip(), value.strip()
112
+
113
+
114
+ def _short_time(iso: str) -> str:
115
+ try:
116
+ return datetime.fromisoformat(iso).strftime("%H:%M:%S")
117
+ except ValueError:
118
+ return iso
119
+
120
+
121
+ def _record_to_dict(record: WebhookRecord, *, show_secrets: bool = False) -> dict:
122
+ headers = redact_headers(record.headers, show_secrets=show_secrets)
123
+ return {
124
+ "id": record.id,
125
+ "method": record.method,
126
+ "path": record.path,
127
+ "headers": [list(h) for h in headers],
128
+ "body": record.body_text() if record.body_text() is not None else None,
129
+ "body_bytes": len(record.body),
130
+ "remote_addr": record.remote_addr,
131
+ "received_at": record.received_at,
132
+ "redacted": not show_secrets and has_sensitive(record.headers),
133
+ }
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # commands
138
+ # --------------------------------------------------------------------------- #
139
+ def cmd_serve(args: argparse.Namespace, store: Storage) -> int:
140
+ server = create_server(
141
+ store,
142
+ host=args.host,
143
+ port=args.port,
144
+ response_status=args.status,
145
+ response_body=args.response.encode("utf-8"),
146
+ max_body_bytes=args.max_body,
147
+ max_captures=args.max_captures,
148
+ read_timeout=args.read_timeout,
149
+ )
150
+ host, port = server.server_address
151
+ banner = f"webhook-replay listening on http://{host}:{port}"
152
+ body_cap = f"{args.max_body} bytes" if args.max_body else "unlimited"
153
+ keep_cap = f"{args.max_captures} newest" if args.max_captures else "unlimited"
154
+ read_cap = f"{args.read_timeout:g}s" if args.read_timeout else "none"
155
+ print(_color.paint(banner, "bold", "green"))
156
+ print(_color.paint(f" storing captures in {store.path}", "dim"))
157
+ print(
158
+ _color.paint(
159
+ f" limits: body {body_cap}, retain {keep_cap}, read timeout {read_cap}",
160
+ "dim",
161
+ )
162
+ )
163
+ print(_color.paint(" point your webhook here, then Ctrl-C to stop", "dim"), flush=True)
164
+ try:
165
+ server.serve_forever()
166
+ except KeyboardInterrupt:
167
+ print()
168
+ print(_color.paint(f"stopped. {store.count()} request(s) captured.", "dim"))
169
+ finally:
170
+ server.server_close()
171
+ return 0
172
+
173
+
174
+ def cmd_list(args: argparse.Namespace, store: Storage) -> int:
175
+ records = store.list(
176
+ method=args.method,
177
+ path_contains=args.path,
178
+ since=args.since,
179
+ limit=args.limit,
180
+ )
181
+ if args.json:
182
+ payload = [
183
+ _record_to_dict(r, show_secrets=args.show_secrets) for r in records
184
+ ]
185
+ # Keep stdout pure JSON so it stays pipeable; the hint goes to stderr.
186
+ print(json.dumps(payload, indent=2), flush=True)
187
+ if any(item["redacted"] for item in payload):
188
+ print(_color.paint(_REDACTION_HINT, "dim"), file=sys.stderr)
189
+ return 0
190
+ if not records:
191
+ print(_color.paint("no captured requests match.", "dim"))
192
+ return 0
193
+
194
+ for record in records:
195
+ ctype = record.content_type or "-"
196
+ line = (
197
+ f"{_color.paint('#' + str(record.id), 'bold'):>6} "
198
+ f"{_color.paint(_short_time(record.received_at), 'gray')} "
199
+ f"{_color.method(record.method):<8} {record.path}"
200
+ )
201
+ print(line)
202
+ print(
203
+ _color.paint(
204
+ f" {len(record.body)} bytes {ctype} from {record.remote_addr}",
205
+ "dim",
206
+ )
207
+ )
208
+ print(_color.paint(f"\n{len(records)} request(s).", "dim"))
209
+ return 0
210
+
211
+
212
+ def cmd_show(args: argparse.Namespace, store: Storage) -> int:
213
+ record = store.get(args.id)
214
+ if record is None:
215
+ print(_color.paint(f"no request with id {args.id}", "red"), file=sys.stderr)
216
+ return 1
217
+ if args.raw:
218
+ sys.stdout.buffer.write(record.body)
219
+ return 0
220
+
221
+ print(f"{_color.method(record.method)} {record.path}")
222
+ print(_color.paint(f"#{record.id} {record.received_at} from {record.remote_addr}", "dim"))
223
+ print()
224
+ print(_color.paint("Headers", "bold"))
225
+ for key, value in redact_headers(record.headers, show_secrets=args.show_secrets):
226
+ print(f" {_color.paint(key, 'cyan')}: {value}")
227
+ if not args.show_secrets and has_sensitive(record.headers):
228
+ print(_color.paint(f" ({_REDACTION_HINT})", "dim"))
229
+ print()
230
+ print(_color.paint("Body", "bold"))
231
+ text = record.body_text()
232
+ if text is None:
233
+ print(_color.paint(f" <{len(record.body)} bytes of binary data>", "dim"))
234
+ return 0
235
+ if not text:
236
+ print(_color.paint(" <empty>", "dim"))
237
+ return 0
238
+ # Pretty-print JSON bodies.
239
+ try:
240
+ parsed = json.loads(text)
241
+ text = json.dumps(parsed, indent=2, ensure_ascii=False)
242
+ except (json.JSONDecodeError, ValueError):
243
+ pass
244
+ for line in text.splitlines():
245
+ print(f" {line}")
246
+ return 0
247
+
248
+
249
+ def _resolve_targets(args: argparse.Namespace, store: Storage) -> list[WebhookRecord]:
250
+ if args.last:
251
+ return list(reversed(store.latest(args.last)))
252
+ records = []
253
+ for request_id in args.ids:
254
+ record = store.get(request_id)
255
+ if record is None:
256
+ print(_color.paint(f"skipping unknown id {request_id}", "yellow"), file=sys.stderr)
257
+ continue
258
+ records.append(record)
259
+ return records
260
+
261
+
262
+ def cmd_replay(args: argparse.Namespace, store: Storage) -> int:
263
+ if args.ids and args.last:
264
+ args.parser.error("pass request id(s) or --last N, not both")
265
+ if not args.ids and not args.last:
266
+ args.parser.error("pass request id(s) or --last N")
267
+ targets = _resolve_targets(args, store)
268
+ if not targets:
269
+ print(_color.paint("nothing to replay.", "yellow"), file=sys.stderr)
270
+ return 1
271
+
272
+ failures = 0
273
+ for record in targets:
274
+ for attempt in range(args.times):
275
+ result = replay(
276
+ record,
277
+ args.to,
278
+ timeout=args.timeout,
279
+ method=args.method,
280
+ extra_headers=args.header or None,
281
+ )
282
+ suffix = f" (x{attempt + 1})" if args.times > 1 else ""
283
+ if result.error is not None:
284
+ failures += 1
285
+ print(
286
+ f"{_color.paint('#' + str(record.id), 'bold')}{suffix} "
287
+ f"{_color.method(result.method)} {result.url} "
288
+ f"{_color.paint('ERROR: ' + result.error, 'red')}"
289
+ )
290
+ continue
291
+ status_style = "green" if result.ok else "red"
292
+ print(
293
+ f"{_color.paint('#' + str(record.id), 'bold')}{suffix} "
294
+ f"{_color.method(result.method)} {result.url} "
295
+ f"-> {_color.paint(str(result.status) + ' ' + result.reason, status_style)} "
296
+ f"{_color.paint(f'{result.elapsed_ms:.0f}ms', 'dim')}"
297
+ )
298
+ if args.show_response and result.body:
299
+ print(_color.paint(" " + result.body_text(limit=2000).replace("\n", "\n "), "dim"))
300
+ return 1 if failures else 0
301
+
302
+
303
+ def cmd_curl(args: argparse.Namespace, store: Storage) -> int:
304
+ record = store.get(args.id)
305
+ if record is None:
306
+ print(_color.paint(f"no request with id {args.id}", "red"), file=sys.stderr)
307
+ return 1
308
+ print(to_curl(record, base_url=args.base, show_secrets=args.show_secrets), flush=True)
309
+ if not args.show_secrets and has_sensitive(record.headers):
310
+ print(_color.paint(_REDACTION_HINT, "dim"), file=sys.stderr)
311
+ return 0
312
+
313
+
314
+ def cmd_diff(args: argparse.Namespace, store: Storage) -> int:
315
+ a = store.get(args.id_a)
316
+ b = store.get(args.id_b)
317
+ missing = [str(i) for i, r in ((args.id_a, a), (args.id_b, b)) if r is None]
318
+ if missing:
319
+ print(_color.paint(f"no request with id {', '.join(missing)}", "red"), file=sys.stderr)
320
+ return 1
321
+ print(diff_records(a, b))
322
+ return 0
323
+
324
+
325
+ def cmd_clear(args: argparse.Namespace, store: Storage) -> int:
326
+ if not args.yes:
327
+ count = store.count()
328
+ answer = input(f"delete all {count} captured request(s)? [y/N] ").strip().lower()
329
+ if answer not in {"y", "yes"}:
330
+ print("aborted.")
331
+ return 0
332
+ removed = store.clear()
333
+ print(_color.paint(f"cleared {removed} request(s).", "dim"))
334
+ return 0
335
+
336
+
337
+ def cmd_prune(args: argparse.Namespace, store: Storage) -> int:
338
+ if not args.older_than and args.keep is None:
339
+ print(
340
+ _color.paint("pass --older-than and/or --keep.", "red"), file=sys.stderr
341
+ )
342
+ return 2
343
+
344
+ removed = 0
345
+ if args.older_than:
346
+ removed += store.prune_older_than(args.older_than)
347
+ if args.keep is not None:
348
+ removed += store.prune_to(args.keep)
349
+ print(
350
+ _color.paint(
351
+ f"pruned {removed} request(s); {store.count()} left.", "dim"
352
+ )
353
+ )
354
+ return 0
355
+
356
+
357
+ # --------------------------------------------------------------------------- #
358
+ # parser
359
+ # --------------------------------------------------------------------------- #
360
+ def _add_show_secrets(parser: argparse.ArgumentParser) -> None:
361
+ """Add the opt-in that turns masking of sensitive headers back off."""
362
+ parser.add_argument(
363
+ "--show-secrets",
364
+ action="store_true",
365
+ help="print sensitive header values in full instead of masking them",
366
+ )
367
+
368
+
369
+ def build_parser() -> argparse.ArgumentParser:
370
+ parser = argparse.ArgumentParser(
371
+ prog="webhook-replay",
372
+ description="Capture, inspect and re-fire webhooks locally.",
373
+ )
374
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
375
+ parser.add_argument(
376
+ "--db", default=str(DEFAULT_DB), help=f"SQLite store path (default: {DEFAULT_DB})"
377
+ )
378
+ parser.add_argument("--no-color", action="store_true", help="disable colored output")
379
+
380
+ sub = parser.add_subparsers(dest="command", required=True)
381
+
382
+ p_serve = sub.add_parser("serve", help="start the local capture server")
383
+ p_serve.add_argument("--host", default="127.0.0.1")
384
+ p_serve.add_argument("-p", "--port", type=int, default=8000)
385
+ p_serve.add_argument("--status", type=int, default=200, help="status code to reply with")
386
+ p_serve.add_argument("--response", default='{"received": true}', help="response body")
387
+ p_serve.add_argument(
388
+ "--max-body", type=parse_size, default=DEFAULT_MAX_BODY_BYTES, metavar="SIZE",
389
+ help=(
390
+ "reject bodies larger than SIZE with 413, e.g. 512KB or 2MiB "
391
+ f"(default: {DEFAULT_MAX_BODY_BYTES}; 0 disables)"
392
+ ),
393
+ )
394
+ p_serve.add_argument(
395
+ "--max-captures", type=int, default=DEFAULT_MAX_CAPTURES, metavar="N",
396
+ help=(
397
+ "keep only the N newest captures, discarding older ones "
398
+ f"(default: {DEFAULT_MAX_CAPTURES}; 0 disables)"
399
+ ),
400
+ )
401
+ p_serve.add_argument(
402
+ "--read-timeout", type=float, default=DEFAULT_READ_TIMEOUT, metavar="SECONDS",
403
+ help=(
404
+ "drop a connection that stalls mid-request "
405
+ f"(default: {DEFAULT_READ_TIMEOUT:g}; 0 disables)"
406
+ ),
407
+ )
408
+ p_serve.set_defaults(func=cmd_serve)
409
+
410
+ p_list = sub.add_parser("list", help="list captured requests")
411
+ p_list.add_argument("--method", help="filter by HTTP method")
412
+ p_list.add_argument("--path", help="filter by substring of the path")
413
+ p_list.add_argument(
414
+ "--since", type=parse_since, metavar="AGE",
415
+ help="only newer than e.g. 10m, 2h, 1d (case-insensitive), or an ISO time",
416
+ )
417
+ p_list.add_argument("--limit", type=int, default=50)
418
+ p_list.add_argument("--json", action="store_true", help="output as JSON")
419
+ _add_show_secrets(p_list)
420
+ p_list.set_defaults(func=cmd_list)
421
+
422
+ p_show = sub.add_parser("show", help="show one request in full")
423
+ p_show.add_argument("id", type=int)
424
+ p_show.add_argument("--raw", action="store_true", help="write only the raw body to stdout")
425
+ _add_show_secrets(p_show)
426
+ p_show.set_defaults(func=cmd_show)
427
+
428
+ p_replay = sub.add_parser("replay", help="re-send captured request(s) to a URL")
429
+ p_replay.add_argument("ids", type=int, nargs="*", help="request id(s) to replay")
430
+ p_replay.add_argument("--to", required=True, help="base URL of your local app")
431
+ p_replay.add_argument("--last", type=int, metavar="N", help="replay the N most recent instead")
432
+ p_replay.add_argument(
433
+ "--times", type=parse_positive_int, default=1, metavar="N",
434
+ help="send each request N times (N >= 1)",
435
+ )
436
+ p_replay.add_argument("--timeout", type=float, default=10.0)
437
+ p_replay.add_argument("--method", help="override the HTTP method")
438
+ p_replay.add_argument(
439
+ "--header", type=parse_header, action="append", metavar="'K: V'",
440
+ help="add/override a header (repeatable)",
441
+ )
442
+ p_replay.add_argument("--show-response", action="store_true", help="print each response body")
443
+ p_replay.set_defaults(func=cmd_replay, parser=p_replay)
444
+
445
+ p_curl = sub.add_parser("curl", help="export a request as a curl command")
446
+ p_curl.add_argument("id", type=int)
447
+ p_curl.add_argument("--base", default="http://localhost:8000", help="base URL for the curl call")
448
+ _add_show_secrets(p_curl)
449
+ p_curl.set_defaults(func=cmd_curl)
450
+
451
+ p_diff = sub.add_parser("diff", help="diff the bodies of two requests")
452
+ p_diff.add_argument("id_a", type=int)
453
+ p_diff.add_argument("id_b", type=int)
454
+ p_diff.set_defaults(func=cmd_diff)
455
+
456
+ p_clear = sub.add_parser("clear", help="delete all captured requests")
457
+ p_clear.add_argument("-y", "--yes", action="store_true", help="do not prompt")
458
+ p_clear.set_defaults(func=cmd_clear)
459
+
460
+ p_prune = sub.add_parser("prune", help="delete old captures, keep the rest")
461
+ p_prune.add_argument(
462
+ "--older-than", type=parse_since, metavar="AGE",
463
+ help=(
464
+ "delete captures older than e.g. 7d, 12h, 10m (case-insensitive), "
465
+ "or an ISO timestamp"
466
+ ),
467
+ )
468
+ p_prune.add_argument(
469
+ "--keep", type=parse_keep, metavar="N",
470
+ help="delete all but the N newest captures (N >= 1; use `clear` to delete all)",
471
+ )
472
+ p_prune.set_defaults(func=cmd_prune)
473
+
474
+ return parser
475
+
476
+
477
+ def main(argv: list[str] | None = None) -> int:
478
+ parser = build_parser()
479
+ args = parser.parse_args(argv)
480
+ if args.no_color:
481
+ _color.set_enabled(False)
482
+ store = Storage(args.db)
483
+ return args.func(args, store)
484
+
485
+
486
+ if __name__ == "__main__": # pragma: no cover
487
+ sys.exit(main())
webhook_replay/diff.py ADDED
@@ -0,0 +1,60 @@
1
+ """Diff the bodies of two captured requests.
2
+
3
+ When both bodies parse as JSON they are pretty-printed with sorted keys first,
4
+ so the diff shows semantic changes instead of key-ordering noise. Otherwise a
5
+ plain text diff is produced.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import json
11
+
12
+ from . import _color
13
+ from .storage import WebhookRecord
14
+
15
+
16
+ def _normalize(body: bytes) -> tuple[str, bool]:
17
+ """Return (text, is_json). JSON is canonicalized for a stable diff."""
18
+ try:
19
+ text = body.decode("utf-8")
20
+ except UnicodeDecodeError:
21
+ return f"<{len(body)} bytes of binary data>", False
22
+ try:
23
+ parsed = json.loads(text)
24
+ except (json.JSONDecodeError, ValueError):
25
+ return text, False
26
+ return json.dumps(parsed, indent=2, sort_keys=True, ensure_ascii=False), True
27
+
28
+
29
+ def diff_records(a: WebhookRecord, b: WebhookRecord) -> str:
30
+ """Produce a colorized unified diff between two records' bodies."""
31
+ text_a, json_a = _normalize(a.body)
32
+ text_b, json_b = _normalize(b.body)
33
+
34
+ lines = difflib.unified_diff(
35
+ text_a.splitlines(),
36
+ text_b.splitlines(),
37
+ fromfile=f"#{a.id} ({a.method} {a.path})",
38
+ tofile=f"#{b.id} ({b.method} {b.path})",
39
+ lineterm="",
40
+ )
41
+
42
+ out: list[str] = []
43
+ for line in lines:
44
+ if line.startswith("+++") or line.startswith("---"):
45
+ out.append(_color.paint(line, "bold"))
46
+ elif line.startswith("@@"):
47
+ out.append(_color.paint(line, "cyan"))
48
+ elif line.startswith("+"):
49
+ out.append(_color.paint(line, "green"))
50
+ elif line.startswith("-"):
51
+ out.append(_color.paint(line, "red"))
52
+ else:
53
+ out.append(line)
54
+
55
+ if not out:
56
+ note = "bodies are identical"
57
+ if json_a and json_b:
58
+ note += " (after JSON normalization)"
59
+ return _color.paint(note, "dim")
60
+ return "\n".join(out)
@@ -0,0 +1,45 @@
1
+ """Export a captured request as a runnable ``curl`` command."""
2
+ from __future__ import annotations
3
+
4
+ from shlex import quote
5
+
6
+ from .redact import redact_headers
7
+ from .replay import build_target_url
8
+ from .storage import WebhookRecord
9
+
10
+ # Headers curl sets itself; keeping them would only add noise / conflicts.
11
+ _SKIP_HEADERS = {"host", "content-length", "connection"}
12
+
13
+
14
+ def to_curl(
15
+ record: WebhookRecord,
16
+ base_url: str = "http://localhost:8000",
17
+ *,
18
+ show_secrets: bool = False,
19
+ ) -> str:
20
+ """Render ``record`` as a multi-line, copy-pasteable curl command.
21
+
22
+ Sensitive header values are masked by default so the command is safe to
23
+ paste into an issue or a chat; pass ``show_secrets=True`` for a command that
24
+ actually authenticates.
25
+ """
26
+ url = build_target_url(base_url, record.path)
27
+ parts: list[str] = [f"curl -X {record.method} {quote(url)}"]
28
+
29
+ for key, value in redact_headers(record.headers, show_secrets=show_secrets):
30
+ if key.lower() in _SKIP_HEADERS:
31
+ continue
32
+ parts.append(f"-H {quote(f'{key}: {value}')}")
33
+
34
+ if record.body:
35
+ text = record.body_text()
36
+ if text is not None:
37
+ parts.append(f"--data-binary {quote(text)}")
38
+ else:
39
+ # Binary body: curl can't take it inline safely, so flag it.
40
+ parts.insert(
41
+ 0, f"# note: original body was {len(record.body)} bytes of binary data"
42
+ )
43
+ parts.append("--data-binary @/path/to/body.bin")
44
+
45
+ return " \\\n ".join(parts)