traffic-diff 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.
- shadowdiff/__init__.py +3 -0
- shadowdiff/cli.py +131 -0
- shadowdiff/config.py +57 -0
- shadowdiff/diff.py +240 -0
- shadowdiff/proxy.py +191 -0
- shadowdiff/replay.py +141 -0
- shadowdiff/report.py +299 -0
- shadowdiff/store.py +242 -0
- traffic_diff-0.1.0.dist-info/METADATA +207 -0
- traffic_diff-0.1.0.dist-info/RECORD +13 -0
- traffic_diff-0.1.0.dist-info/WHEEL +4 -0
- traffic_diff-0.1.0.dist-info/entry_points.txt +2 -0
- traffic_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
shadowdiff/__init__.py
ADDED
shadowdiff/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from shadowdiff import __version__
|
|
9
|
+
from shadowdiff.config import ConfigError, load_config
|
|
10
|
+
from shadowdiff.report import build_report, render
|
|
11
|
+
from shadowdiff.store import Store
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
help="Record API traffic, replay it, and report meaningful diffs.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.callback()
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""shadowdiff: record → replay → smart diff → report."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def version() -> None:
|
|
26
|
+
"""Print the shadowdiff version."""
|
|
27
|
+
typer.echo(__version__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DbOption = typer.Option("shadowdiff.db", "--db", help="SQLite database file.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def record(
|
|
35
|
+
target: str = typer.Option(..., help="Upstream base URL to forward to."),
|
|
36
|
+
session: str = typer.Option("default", help="Session name to group recordings under."),
|
|
37
|
+
host: str = typer.Option("127.0.0.1", help="Interface to listen on."),
|
|
38
|
+
port: int = typer.Option(8080, help="Port to listen on."),
|
|
39
|
+
db: str = DbOption,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Run a recording proxy in front of TARGET."""
|
|
42
|
+
import uvicorn
|
|
43
|
+
|
|
44
|
+
from shadowdiff.proxy import create_app
|
|
45
|
+
|
|
46
|
+
store = Store(db)
|
|
47
|
+
typer.echo(f"Recording session '{session}': http://{host}:{port} -> {target} (db: {db})")
|
|
48
|
+
uvicorn.run(
|
|
49
|
+
create_app(target, store, session),
|
|
50
|
+
host=host,
|
|
51
|
+
port=port,
|
|
52
|
+
log_level="warning",
|
|
53
|
+
access_log=False,
|
|
54
|
+
)
|
|
55
|
+
store.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Format(StrEnum):
|
|
59
|
+
md = "md"
|
|
60
|
+
html = "html"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_report(store: Store, run_id: int, fmt: Format, output: Path | None) -> int:
|
|
64
|
+
report = build_report(store, run_id)
|
|
65
|
+
output = output or Path(f"report.{fmt.value}")
|
|
66
|
+
output.write_text(render(report, fmt.value))
|
|
67
|
+
verdict = "FAIL" if report.exit_code else "PASS"
|
|
68
|
+
typer.echo(
|
|
69
|
+
f"{verdict}: {len(report.breaking)} breaking change(s), "
|
|
70
|
+
f"{len(report.warnings)} warning(s), {sum(report.errors.values())} error(s). "
|
|
71
|
+
f"Report: {output}"
|
|
72
|
+
)
|
|
73
|
+
for g in report.breaking:
|
|
74
|
+
typer.echo(f" BREAKING {g.endpoint} {g.path}: {g.detail} on {g.count} request(s)")
|
|
75
|
+
return report.exit_code
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def replay(
|
|
80
|
+
session: str = typer.Option(..., help="Recorded session to replay."),
|
|
81
|
+
target: str = typer.Option(..., help="Base URL of the version under test."),
|
|
82
|
+
concurrency: int = typer.Option(20, min=1, help="Maximum requests in flight."),
|
|
83
|
+
timeout: float = typer.Option(10.0, min=0.1, help="Per-request timeout in seconds."),
|
|
84
|
+
config: Path = typer.Option(Path("shadowdiff.yaml"), help="Ignore-rules file."),
|
|
85
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Report file path."),
|
|
86
|
+
fmt: Format = typer.Option(Format.md, "--format", help="Report format."),
|
|
87
|
+
db: str = DbOption,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Replay SESSION against TARGET, diff responses and write a report.
|
|
90
|
+
|
|
91
|
+
Exits 1 if any breaking difference or request error is found.
|
|
92
|
+
"""
|
|
93
|
+
from shadowdiff.replay import run_replay
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
rules = load_config(config)
|
|
97
|
+
except ConfigError as exc:
|
|
98
|
+
typer.echo(f"error: {exc}", err=True)
|
|
99
|
+
raise typer.Exit(2) from None
|
|
100
|
+
store = Store(db)
|
|
101
|
+
try:
|
|
102
|
+
summary = run_replay(store, session, target, rules, concurrency, timeout)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
typer.echo(f"error: {exc}", err=True)
|
|
105
|
+
raise typer.Exit(2) from None
|
|
106
|
+
typer.echo(
|
|
107
|
+
f"Run {summary.run_id}: replayed {summary.total} request(s) in "
|
|
108
|
+
f"{summary.replay_seconds:.2f}s ({summary.requests_per_second:.0f} req/s), "
|
|
109
|
+
f"{summary.passed} passed, {summary.failed} failed"
|
|
110
|
+
)
|
|
111
|
+
code = _write_report(store, summary.run_id, fmt, output)
|
|
112
|
+
store.close()
|
|
113
|
+
raise typer.Exit(code)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def report(
|
|
118
|
+
run: int | None = typer.Option(None, "--run", help="Run ID (default: latest run)."),
|
|
119
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Report file path."),
|
|
120
|
+
fmt: Format = typer.Option(Format.md, "--format", help="Report format."),
|
|
121
|
+
db: str = DbOption,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Write the report for a replay run. Exits 1 if it has breaking diffs."""
|
|
124
|
+
store = Store(db)
|
|
125
|
+
run_id = run if run is not None else store.latest_run_id()
|
|
126
|
+
if run_id is None or store.run(run_id) is None:
|
|
127
|
+
typer.echo("error: no such run" if run is not None else "error: no runs yet", err=True)
|
|
128
|
+
raise typer.Exit(2)
|
|
129
|
+
code = _write_report(store, run_id, fmt, output)
|
|
130
|
+
store.close()
|
|
131
|
+
raise typer.Exit(code)
|
shadowdiff/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Loading of shadowdiff.yaml ignore rules."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
VALUE_PATTERNS = ("uuid", "iso_timestamp")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Config:
|
|
17
|
+
# Globs matched against a field name (e.g. "*_at"), or against the full
|
|
18
|
+
# JSON path when the pattern starts with "$" (e.g. "$.meta.*").
|
|
19
|
+
ignore_fields: tuple[str, ...] = ()
|
|
20
|
+
# Named value patterns: a change between two values matching the same
|
|
21
|
+
# pattern is noise (e.g. one UUID replaced by another).
|
|
22
|
+
ignore_values: tuple[str, ...] = ()
|
|
23
|
+
# JSON path of a list (indices written as [*]) -> key to match items by,
|
|
24
|
+
# or None to compare the list as a sorted multiset.
|
|
25
|
+
unordered_lists: dict[str, str | None] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(path: str | Path | None) -> Config:
|
|
29
|
+
if path is None:
|
|
30
|
+
return Config()
|
|
31
|
+
path = Path(path)
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return Config()
|
|
34
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
raise ConfigError(f"{path}: expected a mapping at the top level")
|
|
37
|
+
|
|
38
|
+
unknown = set(data) - {"ignore_fields", "ignore_values", "unordered_lists"}
|
|
39
|
+
if unknown:
|
|
40
|
+
raise ConfigError(f"{path}: unknown keys: {', '.join(sorted(unknown))}")
|
|
41
|
+
|
|
42
|
+
ignore_values = tuple(data.get("ignore_values") or ())
|
|
43
|
+
bad = [v for v in ignore_values if v not in VALUE_PATTERNS]
|
|
44
|
+
if bad:
|
|
45
|
+
raise ConfigError(
|
|
46
|
+
f"{path}: unknown ignore_values {bad}; supported: {', '.join(VALUE_PATTERNS)}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
unordered = data.get("unordered_lists") or {}
|
|
50
|
+
if not isinstance(unordered, dict):
|
|
51
|
+
raise ConfigError(f"{path}: unordered_lists must map a list path to a key")
|
|
52
|
+
|
|
53
|
+
return Config(
|
|
54
|
+
ignore_fields=tuple(str(f) for f in data.get("ignore_fields") or ()),
|
|
55
|
+
ignore_values=ignore_values,
|
|
56
|
+
unordered_lists={str(k): (None if v is None else str(v)) for k, v in unordered.items()},
|
|
57
|
+
)
|
shadowdiff/diff.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Structural response diff with noise filtering.
|
|
2
|
+
|
|
3
|
+
Walks two JSON documents and reports each difference with a JSON path and a
|
|
4
|
+
kind. Ignore rules from ``Config`` drop known noise (timestamps, generated IDs,
|
|
5
|
+
reordered lists) so only meaningful changes are reported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from shadowdiff.config import Config
|
|
16
|
+
|
|
17
|
+
VALUE_CHANGED = "value_changed"
|
|
18
|
+
TYPE_CHANGED = "type_changed"
|
|
19
|
+
FIELD_ADDED = "field_added"
|
|
20
|
+
FIELD_REMOVED = "field_removed"
|
|
21
|
+
STATUS_CHANGED = "status_changed"
|
|
22
|
+
|
|
23
|
+
BREAKING_KINDS = frozenset({TYPE_CHANGED, FIELD_REMOVED, STATUS_CHANGED})
|
|
24
|
+
|
|
25
|
+
VALUE_REGEXES = {
|
|
26
|
+
"uuid": re.compile(
|
|
27
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
|
28
|
+
),
|
|
29
|
+
"iso_timestamp": re.compile(
|
|
30
|
+
r"^\d{4}-\d{2}-\d{2}"
|
|
31
|
+
r"([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$"
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
36
|
+
_INDEX = re.compile(r"\[(\d+|[^\[\]]*=[^\[\]]*)\]")
|
|
37
|
+
_MISSING = object()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Difference:
|
|
42
|
+
path: str
|
|
43
|
+
kind: str
|
|
44
|
+
old: Any = None
|
|
45
|
+
new: Any = None
|
|
46
|
+
old_type: str | None = None
|
|
47
|
+
new_type: str | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def breaking(self) -> bool:
|
|
51
|
+
return self.kind in BREAKING_KINDS
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
d = {"path": self.path, "kind": self.kind, "old": self.old, "new": self.new}
|
|
55
|
+
if self.kind == TYPE_CHANGED:
|
|
56
|
+
d["old_type"] = self.old_type
|
|
57
|
+
d["new_type"] = self.new_type
|
|
58
|
+
d["breaking"] = self.breaking
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compare(
|
|
63
|
+
old_status: int, old_body: bytes, new_status: int, new_body: bytes, config: Config
|
|
64
|
+
) -> list[Difference]:
|
|
65
|
+
"""Compare a recorded response with a replayed one."""
|
|
66
|
+
if old_status != new_status:
|
|
67
|
+
return [Difference("$", STATUS_CHANGED, old_status, new_status)]
|
|
68
|
+
|
|
69
|
+
old_doc, old_is_json = _parse(old_body)
|
|
70
|
+
new_doc, new_is_json = _parse(new_body)
|
|
71
|
+
if old_is_json and new_is_json:
|
|
72
|
+
return diff_json(old_doc, new_doc, config)
|
|
73
|
+
if old_is_json != new_is_json:
|
|
74
|
+
old_t = json_type(old_doc) if old_is_json else "text"
|
|
75
|
+
new_t = json_type(new_doc) if new_is_json else "text"
|
|
76
|
+
return [Difference("$", TYPE_CHANGED, old_doc, new_doc, old_t, new_t)]
|
|
77
|
+
if old_doc == new_doc or _same_value_pattern(old_doc, new_doc, config):
|
|
78
|
+
return []
|
|
79
|
+
return [Difference("$", VALUE_CHANGED, old_doc, new_doc)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def diff_json(old: Any, new: Any, config: Config, path: str = "$") -> list[Difference]:
|
|
83
|
+
"""Recursively diff two parsed JSON values."""
|
|
84
|
+
out: list[Difference] = []
|
|
85
|
+
_walk(old, new, path, config, out)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def json_type(value: Any) -> str:
|
|
90
|
+
if value is None:
|
|
91
|
+
return "null"
|
|
92
|
+
if isinstance(value, bool):
|
|
93
|
+
return "boolean"
|
|
94
|
+
if isinstance(value, int | float):
|
|
95
|
+
return "number"
|
|
96
|
+
if isinstance(value, str):
|
|
97
|
+
return "string"
|
|
98
|
+
if isinstance(value, list):
|
|
99
|
+
return "array"
|
|
100
|
+
return "object"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalize_path(path: str) -> str:
|
|
104
|
+
"""Replace concrete list positions/keys with [*] so paths can be grouped."""
|
|
105
|
+
return _INDEX.sub("[*]", path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(body: bytes) -> tuple[Any, bool]:
|
|
109
|
+
if body:
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(body), True
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
return body.decode("utf-8", errors="replace"), False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _walk(old: Any, new: Any, path: str, config: Config, out: list[Difference]) -> None:
|
|
118
|
+
old_t, new_t = json_type(old), json_type(new)
|
|
119
|
+
if old_t != new_t:
|
|
120
|
+
out.append(Difference(path, TYPE_CHANGED, old, new, old_t, new_t))
|
|
121
|
+
elif old_t == "object":
|
|
122
|
+
_walk_object(old, new, path, config, out)
|
|
123
|
+
elif old_t == "array":
|
|
124
|
+
_walk_array(old, new, path, config, out)
|
|
125
|
+
elif old != new and not _same_value_pattern(old, new, config):
|
|
126
|
+
out.append(Difference(path, VALUE_CHANGED, old, new))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _walk_object(old: dict, new: dict, path: str, config: Config, out: list[Difference]) -> None:
|
|
130
|
+
for key in sorted(old.keys() | new.keys()):
|
|
131
|
+
child = _child_path(path, key)
|
|
132
|
+
if _field_ignored(key, child, config.ignore_fields):
|
|
133
|
+
continue
|
|
134
|
+
o, n = old.get(key, _MISSING), new.get(key, _MISSING)
|
|
135
|
+
if n is _MISSING:
|
|
136
|
+
out.append(Difference(child, FIELD_REMOVED, old=o))
|
|
137
|
+
elif o is _MISSING:
|
|
138
|
+
out.append(Difference(child, FIELD_ADDED, new=n))
|
|
139
|
+
else:
|
|
140
|
+
_walk(o, n, child, config, out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _walk_array(old: list, new: list, path: str, config: Config, out: list[Difference]) -> None:
|
|
144
|
+
rule = _unordered_rule(path, config)
|
|
145
|
+
if rule is not _MISSING:
|
|
146
|
+
key = rule
|
|
147
|
+
if key is None:
|
|
148
|
+
_walk_multiset(old, new, path, out)
|
|
149
|
+
return
|
|
150
|
+
old_by_key, new_by_key = _index_by(old, key), _index_by(new, key)
|
|
151
|
+
if old_by_key is not None and new_by_key is not None:
|
|
152
|
+
for k in sorted(old_by_key.keys() | new_by_key.keys(), key=json.dumps):
|
|
153
|
+
child = f"{path}[{key}={json.dumps(k)}]"
|
|
154
|
+
o, n = old_by_key.get(k, _MISSING), new_by_key.get(k, _MISSING)
|
|
155
|
+
if n is _MISSING:
|
|
156
|
+
out.append(Difference(child, FIELD_REMOVED, old=o))
|
|
157
|
+
elif o is _MISSING:
|
|
158
|
+
out.append(Difference(child, FIELD_ADDED, new=n))
|
|
159
|
+
else:
|
|
160
|
+
_walk(o, n, child, config, out)
|
|
161
|
+
return
|
|
162
|
+
# Items without a unique key: fall back to positional comparison.
|
|
163
|
+
|
|
164
|
+
for i in range(max(len(old), len(new))):
|
|
165
|
+
child = f"{path}[{i}]"
|
|
166
|
+
if i >= len(new):
|
|
167
|
+
out.append(Difference(child, FIELD_REMOVED, old=old[i]))
|
|
168
|
+
elif i >= len(old):
|
|
169
|
+
out.append(Difference(child, FIELD_ADDED, new=new[i]))
|
|
170
|
+
else:
|
|
171
|
+
_walk(old[i], new[i], child, config, out)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _walk_multiset(old: list, new: list, path: str, out: list[Difference]) -> None:
|
|
175
|
+
canon = [json.dumps(x, sort_keys=True) for x in old]
|
|
176
|
+
new_canon = [json.dumps(x, sort_keys=True) for x in new]
|
|
177
|
+
removed = Counter(canon) - Counter(new_canon)
|
|
178
|
+
added = Counter(new_canon) - Counter(canon)
|
|
179
|
+
for i, c in enumerate(canon):
|
|
180
|
+
if removed[c]:
|
|
181
|
+
removed[c] -= 1
|
|
182
|
+
out.append(Difference(f"{path}[{i}]", FIELD_REMOVED, old=old[i]))
|
|
183
|
+
for i, c in enumerate(new_canon):
|
|
184
|
+
if added[c]:
|
|
185
|
+
added[c] -= 1
|
|
186
|
+
out.append(Difference(f"{path}[{i}]", FIELD_ADDED, new=new[i]))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _index_by(items: list, key: str) -> dict | None:
|
|
190
|
+
"""Map item[key] -> item, or None if any item lacks a unique, hashable key."""
|
|
191
|
+
index: dict = {}
|
|
192
|
+
for item in items:
|
|
193
|
+
if not isinstance(item, dict) or key not in item:
|
|
194
|
+
return None
|
|
195
|
+
k = item[key]
|
|
196
|
+
if isinstance(k, dict | list) or k in index:
|
|
197
|
+
return None
|
|
198
|
+
index[k] = item
|
|
199
|
+
return index
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _unordered_rule(path: str, config: Config) -> Any:
|
|
203
|
+
if not config.unordered_lists:
|
|
204
|
+
return _MISSING
|
|
205
|
+
if path in config.unordered_lists:
|
|
206
|
+
return config.unordered_lists[path]
|
|
207
|
+
return config.unordered_lists.get(normalize_path(path), _MISSING)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _field_ignored(key: str, path: str, patterns: tuple[str, ...]) -> bool:
|
|
211
|
+
for pattern in patterns:
|
|
212
|
+
if pattern.startswith("$"):
|
|
213
|
+
rx = _glob(pattern)
|
|
214
|
+
if rx.match(path) or rx.match(normalize_path(path)):
|
|
215
|
+
return True
|
|
216
|
+
elif _glob(pattern).match(key):
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@lru_cache(maxsize=256)
|
|
222
|
+
def _glob(pattern: str) -> re.Pattern:
|
|
223
|
+
"""Compile a glob where only * and ? are special (brackets are literal)."""
|
|
224
|
+
parts = (".*" if c == "*" else "." if c == "?" else re.escape(c) for c in pattern)
|
|
225
|
+
return re.compile("".join(parts) + r"\Z")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _same_value_pattern(old: Any, new: Any, config: Config) -> bool:
|
|
229
|
+
if not (isinstance(old, str) and isinstance(new, str)):
|
|
230
|
+
return False
|
|
231
|
+
return any(
|
|
232
|
+
VALUE_REGEXES[name].match(old) and VALUE_REGEXES[name].match(new)
|
|
233
|
+
for name in config.ignore_values
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _child_path(path: str, key: str) -> str:
|
|
238
|
+
if _IDENTIFIER.match(key):
|
|
239
|
+
return f"{path}.{key}"
|
|
240
|
+
return f"{path}[{json.dumps(key)}]"
|
shadowdiff/proxy.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Recording reverse proxy.
|
|
2
|
+
|
|
3
|
+
Forwards every request to an upstream unchanged and records ``GET`` exchanges
|
|
4
|
+
with 2xx-4xx responses. Recording happens off the response path: exchanges go
|
|
5
|
+
onto an in-memory queue that a background task flushes to SQLite in batches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import FastAPI, Request, Response
|
|
14
|
+
|
|
15
|
+
from shadowdiff.store import Exchange, Store
|
|
16
|
+
|
|
17
|
+
MAX_BODY_BYTES = 1024 * 1024
|
|
18
|
+
HOP_BY_HOP = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"connection",
|
|
21
|
+
"keep-alive",
|
|
22
|
+
"proxy-authenticate",
|
|
23
|
+
"proxy-authorization",
|
|
24
|
+
"te",
|
|
25
|
+
"trailer",
|
|
26
|
+
"trailers",
|
|
27
|
+
"transfer-encoding",
|
|
28
|
+
"upgrade",
|
|
29
|
+
"host",
|
|
30
|
+
"content-length",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
# httpx transparently decodes compressed bodies, so the encoding header no longer applies.
|
|
34
|
+
STRIP_FROM_RESPONSE = HOP_BY_HOP | {"content-encoding"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_headers(headers, drop: frozenset[str] = HOP_BY_HOP) -> dict[str, str]:
|
|
38
|
+
return {k: v for k, v in headers.items() if k.lower() not in drop}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_record(method: str, status: int, req_body: bytes, resp_body: bytes) -> bool:
|
|
42
|
+
return (
|
|
43
|
+
method == "GET"
|
|
44
|
+
and 200 <= status < 500
|
|
45
|
+
and len(req_body) <= MAX_BODY_BYTES
|
|
46
|
+
and len(resp_body) <= MAX_BODY_BYTES
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Recorder:
|
|
51
|
+
"""Buffers exchanges and writes them to the store in batches."""
|
|
52
|
+
|
|
53
|
+
_STOP = object()
|
|
54
|
+
|
|
55
|
+
def __init__(self, store: Store, batch_size: int = 200):
|
|
56
|
+
self.store = store
|
|
57
|
+
self.batch_size = batch_size
|
|
58
|
+
self.queue: asyncio.Queue = asyncio.Queue()
|
|
59
|
+
self.recorded = 0
|
|
60
|
+
|
|
61
|
+
def submit(self, exchange: Exchange) -> None:
|
|
62
|
+
self.queue.put_nowait(exchange)
|
|
63
|
+
|
|
64
|
+
def stop(self) -> None:
|
|
65
|
+
"""Ask run() to flush everything submitted so far, then return."""
|
|
66
|
+
self.queue.put_nowait(self._STOP)
|
|
67
|
+
|
|
68
|
+
async def run(self) -> None:
|
|
69
|
+
# Stopping with a sentinel rather than cancelling means a batch is never
|
|
70
|
+
# abandoned mid-write and nothing submitted before stop() is lost.
|
|
71
|
+
stopping = False
|
|
72
|
+
while not stopping:
|
|
73
|
+
batch = []
|
|
74
|
+
item = await self.queue.get()
|
|
75
|
+
while True:
|
|
76
|
+
if item is self._STOP:
|
|
77
|
+
stopping = True
|
|
78
|
+
break
|
|
79
|
+
batch.append(item)
|
|
80
|
+
if len(batch) >= self.batch_size or self.queue.empty():
|
|
81
|
+
break
|
|
82
|
+
item = self.queue.get_nowait()
|
|
83
|
+
if batch:
|
|
84
|
+
await asyncio.to_thread(self.store.add_exchanges, batch)
|
|
85
|
+
self.recorded += len(batch)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ClientPool:
|
|
89
|
+
"""A growable LIFO pool of single-connection httpx clients.
|
|
90
|
+
|
|
91
|
+
One shared client with a large connection pool spends CPU per request
|
|
92
|
+
proportional to the pool size, which added tens of milliseconds of p95
|
|
93
|
+
latency under concurrent load. Single-connection clients avoid that, and
|
|
94
|
+
LIFO reuse keeps the warmest connections busy.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, factory, max_size: int = 200):
|
|
98
|
+
self.factory = factory
|
|
99
|
+
self.max_size = max_size
|
|
100
|
+
self.idle: asyncio.LifoQueue[httpx.AsyncClient] = asyncio.LifoQueue()
|
|
101
|
+
self.clients: list[httpx.AsyncClient] = []
|
|
102
|
+
|
|
103
|
+
@contextlib.asynccontextmanager
|
|
104
|
+
async def client(self):
|
|
105
|
+
if not self.idle.empty():
|
|
106
|
+
client = self.idle.get_nowait()
|
|
107
|
+
elif len(self.clients) < self.max_size:
|
|
108
|
+
client = self.factory()
|
|
109
|
+
self.clients.append(client)
|
|
110
|
+
else:
|
|
111
|
+
client = await self.idle.get()
|
|
112
|
+
try:
|
|
113
|
+
yield client
|
|
114
|
+
finally:
|
|
115
|
+
self.idle.put_nowait(client)
|
|
116
|
+
|
|
117
|
+
async def aclose(self) -> None:
|
|
118
|
+
for client in self.clients:
|
|
119
|
+
await client.aclose()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_app(
|
|
123
|
+
target: str,
|
|
124
|
+
store: Store,
|
|
125
|
+
session: str,
|
|
126
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
127
|
+
) -> FastAPI:
|
|
128
|
+
target = target.rstrip("/")
|
|
129
|
+
|
|
130
|
+
@contextlib.asynccontextmanager
|
|
131
|
+
async def lifespan(app: FastAPI):
|
|
132
|
+
recorder = Recorder(store)
|
|
133
|
+
limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
|
|
134
|
+
pool = ClientPool(
|
|
135
|
+
lambda: httpx.AsyncClient(
|
|
136
|
+
base_url=target, limits=limits, timeout=30, transport=transport
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
app.state.pool = pool
|
|
140
|
+
app.state.recorder = recorder
|
|
141
|
+
writer = asyncio.create_task(recorder.run())
|
|
142
|
+
try:
|
|
143
|
+
yield
|
|
144
|
+
finally:
|
|
145
|
+
recorder.stop()
|
|
146
|
+
await writer
|
|
147
|
+
await pool.aclose()
|
|
148
|
+
|
|
149
|
+
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
150
|
+
|
|
151
|
+
@app.api_route(
|
|
152
|
+
"/{path:path}",
|
|
153
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
154
|
+
)
|
|
155
|
+
async def forward(request: Request, path: str) -> Response:
|
|
156
|
+
pool: ClientPool = request.app.state.pool
|
|
157
|
+
req_body = await request.body()
|
|
158
|
+
req_headers = strip_headers(request.headers)
|
|
159
|
+
query = request.url.query
|
|
160
|
+
url = request.url.path + (f"?{query}" if query else "")
|
|
161
|
+
|
|
162
|
+
start = time.perf_counter()
|
|
163
|
+
try:
|
|
164
|
+
async with pool.client() as client:
|
|
165
|
+
upstream = await client.request(
|
|
166
|
+
request.method, url, headers=req_headers, content=req_body
|
|
167
|
+
)
|
|
168
|
+
except httpx.HTTPError as exc:
|
|
169
|
+
return Response(f"shadowdiff proxy: upstream error: {exc!r}", status_code=502)
|
|
170
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
171
|
+
|
|
172
|
+
resp_headers = strip_headers(upstream.headers, STRIP_FROM_RESPONSE)
|
|
173
|
+
resp_body = upstream.content
|
|
174
|
+
if should_record(request.method, upstream.status_code, req_body, resp_body):
|
|
175
|
+
request.app.state.recorder.submit(
|
|
176
|
+
Exchange(
|
|
177
|
+
session=session,
|
|
178
|
+
method=request.method,
|
|
179
|
+
path=request.url.path,
|
|
180
|
+
query=query,
|
|
181
|
+
req_headers=req_headers,
|
|
182
|
+
req_body=req_body,
|
|
183
|
+
status=upstream.status_code,
|
|
184
|
+
resp_headers=resp_headers,
|
|
185
|
+
resp_body=resp_body,
|
|
186
|
+
latency_ms=latency_ms,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return Response(content=resp_body, status_code=upstream.status_code, headers=resp_headers)
|
|
190
|
+
|
|
191
|
+
return app
|