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.
- webhook_replay/__init__.py +29 -0
- webhook_replay/__main__.py +9 -0
- webhook_replay/_color.py +60 -0
- webhook_replay/cli.py +487 -0
- webhook_replay/diff.py +60 -0
- webhook_replay/export.py +45 -0
- webhook_replay/redact.py +83 -0
- webhook_replay/replay.py +117 -0
- webhook_replay/server.py +326 -0
- webhook_replay/storage.py +217 -0
- webhook_replay-0.1.0.dist-info/METADATA +275 -0
- webhook_replay-0.1.0.dist-info/RECORD +16 -0
- webhook_replay-0.1.0.dist-info/WHEEL +5 -0
- webhook_replay-0.1.0.dist-info/entry_points.txt +2 -0
- webhook_replay-0.1.0.dist-info/licenses/LICENSE +21 -0
- webhook_replay-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
webhook_replay/_color.py
ADDED
|
@@ -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)
|
webhook_replay/export.py
ADDED
|
@@ -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)
|