django-http-inspector 0.1.4__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.
Files changed (33) hide show
  1. django_http_inspector/__init__.py +6 -0
  2. django_http_inspector/capture/__init__.py +0 -0
  3. django_http_inspector/capture/exchange.py +84 -0
  4. django_http_inspector/capture/headers.py +33 -0
  5. django_http_inspector/capture/url.py +51 -0
  6. django_http_inspector/config.py +102 -0
  7. django_http_inspector/inspector/__init__.py +0 -0
  8. django_http_inspector/inspector/app.py +292 -0
  9. django_http_inspector/inspector/presentation.py +195 -0
  10. django_http_inspector/inspector/security.py +45 -0
  11. django_http_inspector/inspector/templates.py +21 -0
  12. django_http_inspector/replay/__init__.py +0 -0
  13. django_http_inspector/replay/edit.py +119 -0
  14. django_http_inspector/replay/service.py +69 -0
  15. django_http_inspector/replay/target.py +68 -0
  16. django_http_inspector/replay/transport.py +85 -0
  17. django_http_inspector/static/django_http_inspector/inspect.css +139 -0
  18. django_http_inspector/static/django_http_inspector/inspect.js +210 -0
  19. django_http_inspector/storage/__init__.py +3 -0
  20. django_http_inspector/storage/records.py +84 -0
  21. django_http_inspector/storage/repository.py +276 -0
  22. django_http_inspector/storage/schema.py +40 -0
  23. django_http_inspector/templates/django_http_inspector/base.html +13 -0
  24. django_http_inspector/templates/django_http_inspector/index.html +101 -0
  25. django_http_inspector/wrapper/__init__.py +0 -0
  26. django_http_inspector/wrapper/input.py +68 -0
  27. django_http_inspector/wrapper/response.py +38 -0
  28. django_http_inspector/wrapper/wsgi.py +78 -0
  29. django_http_inspector-0.1.4.dist-info/METADATA +129 -0
  30. django_http_inspector-0.1.4.dist-info/RECORD +33 -0
  31. django_http_inspector-0.1.4.dist-info/WHEEL +5 -0
  32. django_http_inspector-0.1.4.dist-info/licenses/LICENSE +21 -0
  33. django_http_inspector-0.1.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Public API for django-http-inspector."""
2
+
3
+ from .wrapper.wsgi import InspectorWSGI
4
+
5
+ __all__ = ["InspectorWSGI"]
6
+ __version__ = "0.1.4"
File without changes
@@ -0,0 +1,84 @@
1
+ import logging
2
+ import time
3
+
4
+ from django.utils import timezone
5
+
6
+ from django_http_inspector.storage.records import ExchangeRecord
7
+
8
+ logger = logging.getLogger("django_http_inspector")
9
+
10
+
11
+ class ExchangeCapture:
12
+ def __init__(self, environ, input_stream, config, url_data, headers, repository):
13
+ self.environ = environ
14
+ self.input_stream = input_stream
15
+ self.config = config
16
+ self.started = time.monotonic()
17
+ self.response_status = None
18
+ self.response_headers = []
19
+ self.response_body = bytearray()
20
+ self.response_size = 0
21
+ self.response_incomplete = False
22
+ self.error_summary = ""
23
+ self.finalized = False
24
+ self.exchange = None
25
+ self.repository = repository
26
+ url, scheme, host, provenance = url_data
27
+ if not repository.available:
28
+ return
29
+ nonce = next((value for name, value in headers if name.lower() == "x-django-http-inspector-replay"), None)
30
+ try:
31
+ self.exchange = repository.create_exchange(
32
+ correlation_nonce=nonce,
33
+ method=str(environ.get("REQUEST_METHOD", "GET")),
34
+ url=url,
35
+ url_provenance=provenance,
36
+ scheme=scheme,
37
+ host=host,
38
+ path=str(environ.get("PATH_INFO", "/")),
39
+ query_string=str(environ.get("QUERY_STRING", "")),
40
+ request_headers=headers,
41
+ request_content_type=str(environ.get("CONTENT_TYPE", "")),
42
+ request_declared_size=input_stream.declared_size,
43
+ client_addr=str(environ.get("REMOTE_ADDR", "")),
44
+ )
45
+ except Exception:
46
+ logger.exception("Unable to create django-http-inspector exchange")
47
+
48
+ def start(self, status, headers):
49
+ self.response_status = int(str(status).split(" ", 1)[0])
50
+ self.response_headers = [[str(name), str(value)] for name, value in headers]
51
+
52
+ def observe_response(self, data):
53
+ self.response_size += len(data)
54
+ remaining = self.config.capture_max_bytes - len(self.response_body)
55
+ if remaining > 0:
56
+ self.response_body.extend(data[:remaining])
57
+
58
+ def finalize(self, error=None, incomplete=False):
59
+ if not self.exchange or self.finalized:
60
+ return
61
+ self.finalized = True
62
+ now = timezone.now()
63
+ if error:
64
+ self.error_summary = f"{type(error).__name__}: {error}"[:2000]
65
+ try:
66
+ self.exchange.completed_at = now
67
+ self.exchange.duration_ms = (time.monotonic() - self.started) * 1000
68
+ self.exchange.request_body = bytes(self.input_stream.captured)
69
+ self.exchange.request_observed_size = self.input_stream.observed_size
70
+ self.exchange.request_captured_size = len(self.input_stream.captured)
71
+ self.exchange.request_body_truncated = self.input_stream.truncated
72
+ self.exchange.request_body_incomplete = self.input_stream.incomplete
73
+ self.exchange.response_status = self.response_status
74
+ self.exchange.response_headers = self.response_headers
75
+ self.exchange.response_body = bytes(self.response_body)
76
+ self.exchange.response_size = self.response_size
77
+ self.exchange.response_body_truncated = self.response_size > self.config.capture_max_bytes
78
+ self.exchange.response_body_incomplete = incomplete
79
+ self.exchange.state = ExchangeRecord.State.APPLICATION_ERROR if error else ExchangeRecord.State.COMPLETE
80
+ self.exchange.error_summary = self.error_summary
81
+ self.repository.update_exchange(self.exchange)
82
+ self.repository.prune(self.config.max_records)
83
+ except Exception:
84
+ logger.exception("Unable to finalize django-http-inspector exchange")
@@ -0,0 +1,33 @@
1
+ HOP_BY_HOP_HEADERS = {
2
+ "connection",
3
+ "keep-alive",
4
+ "proxy-authenticate",
5
+ "proxy-authorization",
6
+ "te",
7
+ "trailer",
8
+ "transfer-encoding",
9
+ "upgrade",
10
+ }
11
+
12
+
13
+ def request_headers_from_environ(environ):
14
+ headers = []
15
+ if environ.get("CONTENT_TYPE"):
16
+ headers.append(["Content-Type", str(environ["CONTENT_TYPE"])])
17
+ if environ.get("CONTENT_LENGTH"):
18
+ headers.append(["Content-Length", str(environ["CONTENT_LENGTH"])])
19
+ for key, value in environ.items():
20
+ if key.startswith("HTTP_"):
21
+ name = "-".join(part.title() for part in key[5:].split("_"))
22
+ headers.append([name, str(value)])
23
+ return headers
24
+
25
+
26
+ def replay_headers(headers):
27
+ result = []
28
+ for name, value in headers:
29
+ lower = name.lower()
30
+ if lower in HOP_BY_HOP_HEADERS or lower in {"content-length", "host", "x-django-http-inspector-replay"}:
31
+ continue
32
+ result.append((name, value))
33
+ return result
@@ -0,0 +1,51 @@
1
+ from ipaddress import ip_address, ip_network
2
+ from urllib.parse import quote
3
+
4
+
5
+ def _trusted(remote_addr, cidrs):
6
+ try:
7
+ address = ip_address(remote_addr)
8
+ return any(address in ip_network(cidr, strict=False) for cidr in cidrs)
9
+ except ValueError:
10
+ return False
11
+
12
+
13
+ def _forwarded(environ):
14
+ value = environ.get("HTTP_FORWARDED", "")
15
+ if not value:
16
+ return None, None
17
+ nearest = value.split(",")[-1]
18
+ parts = {}
19
+ for item in nearest.split(";"):
20
+ key, separator, raw = item.strip().partition("=")
21
+ if separator:
22
+ parts[key.lower()] = raw.strip().strip('"')
23
+ return parts.get("proto"), parts.get("host")
24
+
25
+
26
+ def build_url(environ, trusted_proxy_cidrs=()):
27
+ scheme = str(environ.get("wsgi.url_scheme", "http")).lower()
28
+ host = str(environ.get("HTTP_HOST") or "")
29
+ provenance = "reconstructed"
30
+ if _trusted(str(environ.get("REMOTE_ADDR", "")), trusted_proxy_cidrs):
31
+ forwarded_scheme, forwarded_host = _forwarded(environ)
32
+ scheme = forwarded_scheme or str(environ.get("HTTP_X_FORWARDED_PROTO", "")).split(",")[-1].strip() or scheme
33
+ host = forwarded_host or str(environ.get("HTTP_X_FORWARDED_HOST", "")).split(",")[-1].strip() or host
34
+ provenance = "trusted_proxy"
35
+ if scheme not in {"http", "https"} or not host or any(ch in host for ch in "\r\n/@"):
36
+ return "", scheme, host, provenance
37
+
38
+ raw_uri = environ.get("RAW_URI") or environ.get("REQUEST_URI")
39
+ if raw_uri:
40
+ target = str(raw_uri)
41
+ provenance = "server_specific"
42
+ if not target.startswith("/"):
43
+ target = "/" + target
44
+ else:
45
+ script = quote(str(environ.get("SCRIPT_NAME", "")), safe="/%:@")
46
+ path = quote(str(environ.get("PATH_INFO", "/")), safe="/%:@")
47
+ target = script + path
48
+ query = str(environ.get("QUERY_STRING", ""))
49
+ if query:
50
+ target += "?" + query
51
+ return f"{scheme}://{host}{target}", scheme, host, provenance
@@ -0,0 +1,102 @@
1
+ from dataclasses import dataclass
2
+ from ipaddress import ip_network
3
+ from os import PathLike
4
+ from pathlib import Path
5
+ from typing import Tuple
6
+
7
+ from django.conf import settings
8
+ from django.core.exceptions import ImproperlyConfigured
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class InspectConfig:
13
+ enabled: bool
14
+ allow_remote: bool
15
+ path: str
16
+ capture_max_bytes: int
17
+ max_records: int
18
+ exclude_paths: Tuple[str, ...]
19
+ trusted_proxy_cidrs: Tuple[str, ...]
20
+ inspector_allowed_hosts: Tuple[str, ...]
21
+ inspector_allowed_client_cidrs: Tuple[str, ...]
22
+ replay_timeout: float
23
+ sqlite_path: Path
24
+
25
+ def is_inspector_path(self, path: str) -> bool:
26
+ base = self.path.rstrip("/")
27
+ return path == base or path.startswith(base + "/")
28
+
29
+ def is_excluded_path(self, path: str) -> bool:
30
+ return self.is_inspector_path(path) or any(
31
+ path == prefix.rstrip("/") or path.startswith(prefix)
32
+ for prefix in self.exclude_paths
33
+ )
34
+
35
+
36
+ def _path(value: object, name: str) -> str:
37
+ if not isinstance(value, str) or not value.startswith("/"):
38
+ raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must start with '/'.")
39
+ return value.rstrip("/") + "/"
40
+
41
+
42
+ def _positive_number(value: object, name: str, number_type):
43
+ if isinstance(value, bool) or not isinstance(value, number_type) or value <= 0:
44
+ raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be positive.")
45
+ return value
46
+
47
+
48
+ def _boolean(value: object, name: str) -> bool:
49
+ if type(value) is not bool:
50
+ raise ImproperlyConfigured(f"DJANGO_HTTP_INSPECTOR[{name!r}] must be a boolean.")
51
+ return value
52
+
53
+
54
+ def load_config() -> InspectConfig:
55
+ raw = getattr(settings, "DJANGO_HTTP_INSPECTOR", {})
56
+ if not isinstance(raw, dict):
57
+ raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR must be a dictionary.")
58
+
59
+ allow_remote = _boolean(raw.get("ALLOW_REMOTE", False), "ALLOW_REMOTE")
60
+ trusted = tuple(raw.get("TRUSTED_PROXY_CIDRS", ()))
61
+ clients = tuple(raw.get("INSPECTOR_ALLOWED_CLIENT_CIDRS", ("127.0.0.0/8", "::1/128")))
62
+ try:
63
+ for cidr in trusted + clients:
64
+ ip_network(cidr, strict=False)
65
+ except (TypeError, ValueError) as exc:
66
+ raise ImproperlyConfigured(f"Invalid django-http-inspector CIDR: {exc}") from exc
67
+ if not allow_remote and any(not ip_network(cidr, strict=False).is_loopback for cidr in clients):
68
+ raise ImproperlyConfigured(
69
+ "MVP Inspector access is loopback-only; non-loopback authentication is not implemented."
70
+ )
71
+
72
+ hosts = tuple(raw.get("INSPECTOR_ALLOWED_HOSTS", ("localhost", "127.0.0.1", "[::1]")))
73
+ if not hosts or not all(isinstance(host, str) and host for host in hosts):
74
+ raise ImproperlyConfigured("INSPECTOR_ALLOWED_HOSTS must contain host names.")
75
+
76
+ base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
77
+ sqlite_value = raw.get("SQLITE_PATH", base_dir / ".django-http-inspector.sqlite3")
78
+ if not isinstance(sqlite_value, (str, PathLike)) or not str(sqlite_value):
79
+ raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a non-empty path.")
80
+ sqlite_path = Path(sqlite_value)
81
+ if not sqlite_path.is_absolute():
82
+ sqlite_path = base_dir / sqlite_path
83
+ sqlite_path = sqlite_path.resolve()
84
+ if sqlite_path.exists() and sqlite_path.is_dir():
85
+ raise ImproperlyConfigured("DJANGO_HTTP_INSPECTOR['SQLITE_PATH'] must be a file, not a directory.")
86
+
87
+ return InspectConfig(
88
+ enabled=bool(raw.get("ENABLED", settings.DEBUG)),
89
+ allow_remote=allow_remote,
90
+ path=_path(raw.get("PATH", "/__inspect/"), "PATH"),
91
+ capture_max_bytes=_positive_number(raw.get("CAPTURE_MAX_BYTES", 1024 * 1024), "CAPTURE_MAX_BYTES", int),
92
+ max_records=_positive_number(raw.get("MAX_RECORDS", 1000), "MAX_RECORDS", int),
93
+ exclude_paths=tuple(_path(p, "EXCLUDE_PATHS") for p in raw.get(
94
+ "EXCLUDE_PATHS",
95
+ ("/static/", "/favicon.ico", "/.well-known/appspecific/com.chrome.devtools.json"),
96
+ )),
97
+ trusted_proxy_cidrs=trusted,
98
+ inspector_allowed_hosts=hosts,
99
+ inspector_allowed_client_cidrs=clients,
100
+ replay_timeout=float(_positive_number(raw.get("REPLAY_TIMEOUT", 10), "REPLAY_TIMEOUT", (int, float))),
101
+ sqlite_path=sqlite_path,
102
+ )
File without changes
@@ -0,0 +1,292 @@
1
+ import hashlib
2
+ import json
3
+ from html import escape
4
+ from importlib.resources import files
5
+ from urllib.parse import parse_qs
6
+
7
+ from django_http_inspector.inspector.presentation import present_body
8
+ from django_http_inspector.inspector.security import mutation_allowed, request_allowed
9
+ from django_http_inspector.inspector.templates import render
10
+ from django_http_inspector.replay.edit import EditValidationError, editable_body, encode_edited_body, headers_to_text, parse_headers
11
+ from django_http_inspector.replay.service import can_replay, replay_exchange
12
+ from django_http_inspector.storage.records import ReplayAttemptRecord
13
+
14
+
15
+ STATUS_TEXT = {
16
+ 200: "OK", 302: "Found", 303: "See Other", 400: "Bad Request", 403: "Forbidden",
17
+ 404: "Not Found", 409: "Conflict", 413: "Content Too Large", 500: "Internal Server Error",
18
+ }
19
+
20
+
21
+ class PayloadError(ValueError):
22
+ def __init__(self, status, code, message):
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.code = code
26
+ self.message = message
27
+
28
+
29
+ class InspectorApp:
30
+ def __init__(self, config, token, repository):
31
+ self.config = config
32
+ self.token = token
33
+ self.repository = repository
34
+ self.base = config.path.rstrip("/")
35
+
36
+ def response(self, start_response, content, status=200, content_type="text/html; charset=utf-8", headers=()):
37
+ body = content.encode("utf-8") if isinstance(content, str) else content
38
+ response_headers = [("Content-Type", content_type), ("Content-Length", str(len(body))), *headers]
39
+ start_response(f"{status} {STATUS_TEXT.get(status, '')}".strip(), response_headers)
40
+ return [body]
41
+
42
+ def json_response(self, start_response, payload, status=200):
43
+ return self.response(
44
+ start_response,
45
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
46
+ status,
47
+ "application/json; charset=utf-8",
48
+ (("Cache-Control", "no-store"),),
49
+ )
50
+
51
+ def redirect(self, start_response, location):
52
+ return self.response(start_response, b"", 303, headers=(("Location", location),))
53
+
54
+ def form(self, environ):
55
+ try:
56
+ length = min(int(environ.get("CONTENT_LENGTH", "0") or 0), 64 * 1024)
57
+ except ValueError:
58
+ length = 0
59
+ return parse_qs(environ["wsgi.input"].read(length).decode("utf-8", "replace"))
60
+
61
+ def read_json(self, environ):
62
+ limit = self.config.capture_max_bytes + 256 * 1024
63
+ try:
64
+ declared = int(environ.get("CONTENT_LENGTH", "0") or 0)
65
+ except (TypeError, ValueError):
66
+ raise PayloadError(400, "invalid_payload", "Content-Length is invalid.")
67
+ if declared > limit:
68
+ raise PayloadError(413, "payload_too_large", "Edit & Replay payload is too large.")
69
+ content_type = str(environ.get("CONTENT_TYPE", "")).split(";", 1)[0].strip().lower()
70
+ if content_type != "application/json":
71
+ raise PayloadError(400, "invalid_payload", "Content-Type must be application/json.")
72
+ data = environ["wsgi.input"].read(limit + 1)
73
+ if len(data) > limit:
74
+ raise PayloadError(413, "payload_too_large", "Edit & Replay payload is too large.")
75
+ try:
76
+ payload = json.loads(data.decode("utf-8", "strict"))
77
+ except (UnicodeDecodeError, json.JSONDecodeError):
78
+ raise PayloadError(400, "invalid_payload", "Request body must be valid UTF-8 JSON.")
79
+ if not isinstance(payload, dict):
80
+ raise PayloadError(400, "invalid_payload", "JSON payload must be an object.")
81
+ allowed = {"token", "headers_text", "body_text"}
82
+ unknown = set(payload) - allowed
83
+ if unknown:
84
+ raise PayloadError(400, "unexpected_field", f"Unexpected field: {sorted(unknown)[0]}.")
85
+ if set(payload) != allowed or not all(isinstance(payload[name], str) for name in allowed):
86
+ raise PayloadError(400, "invalid_payload", "token, headers_text, and body_text are required strings.")
87
+ return payload
88
+
89
+ def __call__(self, environ, start_response):
90
+ if not request_allowed(environ, self.config):
91
+ return self.response(start_response, "Inspector access denied.", 403, "text/plain; charset=utf-8")
92
+ path = str(environ.get("PATH_INFO", "/"))
93
+ method = str(environ.get("REQUEST_METHOD", "GET")).upper()
94
+ relative = path[len(self.base):] or "/"
95
+ if relative.startswith("/assets/") and method == "GET":
96
+ return self.asset(relative, start_response)
97
+ if relative == "/" and method == "GET":
98
+ return self.index(start_response)
99
+ if relative == "/api/exchanges" and method == "GET":
100
+ return self.exchange_snapshot(start_response)
101
+ if relative == "/clear" and method == "POST":
102
+ return self.clear(environ, start_response)
103
+ parts = [part for part in relative.split("/") if part]
104
+ if len(parts) == 2 and parts[0] == "requests" and method == "GET":
105
+ return self.detail(parts[1], start_response)
106
+ if len(parts) == 3 and parts[0] == "requests" and parts[2] == "replay" and method == "POST":
107
+ return self.replay(parts[1], environ, start_response)
108
+ if len(parts) == 3 and parts[0] == "requests" and parts[2] == "edit-replay" and method == "POST":
109
+ return self.edit_replay(parts[1], environ, start_response)
110
+ return self.response(start_response, "Not found", 404, "text/plain; charset=utf-8")
111
+
112
+ def lightweight_snapshot(self):
113
+ exchanges = self.repository.list_exchanges(limit=200)
114
+ rows = [{
115
+ "id": exchange.id, "method": exchange.method, "path": exchange.path, "state": exchange.state,
116
+ "response_status": exchange.response_status, "duration_ms": exchange.duration_ms,
117
+ "created_at": exchange.created_at.isoformat().replace("+00:00", "Z"),
118
+ } for exchange in exchanges]
119
+ total = self.repository.count_exchanges()
120
+ canonical = json.dumps({"exchanges": rows, "total": total}, sort_keys=True, separators=(",", ":"))
121
+ return rows, total, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
122
+
123
+ def exchange_snapshot(self, start_response):
124
+ if not self.repository.available:
125
+ return self.json_error(start_response, 500, "storage_unavailable", self.repository.error)
126
+ try:
127
+ rows, total, cursor = self.lightweight_snapshot()
128
+ return self.json_response(start_response, {"cursor": cursor, "exchanges": rows, "total": total})
129
+ except Exception as exc:
130
+ return self.json_error(start_response, 500, "storage_unavailable", str(exc))
131
+
132
+ def context(self, selected=None, message=""):
133
+ exchanges = self.repository.list_exchanges(limit=200)
134
+ body = body_kind = response_body = response_kind = ""
135
+ request_parts = response_parts = None
136
+ attempts = []
137
+ edit_allowed = False
138
+ edit_body = edit_reason = ""
139
+ if selected:
140
+ body, body_kind, request_parts = present_body(
141
+ selected.request_body,
142
+ selected.request_content_type,
143
+ complete=not (selected.request_body_truncated or selected.request_body_incomplete),
144
+ )
145
+ response_content_type = next((v for n, v in selected.response_headers if n.lower() == "content-type"), "")
146
+ response_body, response_kind, response_parts = present_body(
147
+ selected.response_body,
148
+ response_content_type,
149
+ complete=not (selected.response_body_truncated or selected.response_body_incomplete),
150
+ )
151
+ attempts = self.repository.list_attempts(selected.id, limit=20)
152
+ edit_allowed, edit_body, edit_reason = editable_body(selected)
153
+ _, total, cursor = self.lightweight_snapshot()
154
+ return {
155
+ "base": self.base, "token": self.token, "exchanges": exchanges, "exchange_total": total,
156
+ "list_cursor": cursor, "selected": selected, "request_body": body, "request_body_kind": body_kind,
157
+ "request_multipart_parts": request_parts, "response_body": response_body,
158
+ "response_body_kind": response_kind, "response_multipart_parts": response_parts, "attempts": attempts,
159
+ "can_replay": bool(selected and can_replay(selected)),
160
+ "can_edit": bool(selected and can_replay(selected) and edit_allowed), "edit_reason": edit_reason,
161
+ "edit_headers": headers_to_text(selected.request_headers) if selected else "", "edit_body": edit_body,
162
+ "message": message,
163
+ }
164
+
165
+ def index(self, start_response):
166
+ if not self.repository.available:
167
+ return self.storage_error(start_response)
168
+ try:
169
+ exchanges = self.repository.list_exchanges(limit=1)
170
+ return self.response(start_response, render("index.html", self.context(exchanges[0] if exchanges else None)))
171
+ except Exception as exc:
172
+ return self.storage_error(start_response, exc)
173
+
174
+ def detail(self, exchange_id, start_response, message=""):
175
+ try:
176
+ exchange = self.repository.get_exchange(int(exchange_id))
177
+ if exchange is None:
178
+ return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
179
+ return self.response(start_response, render("index.html", self.context(exchange, message)))
180
+ except (TypeError, ValueError):
181
+ return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
182
+ except Exception as exc:
183
+ return self.storage_error(start_response, exc)
184
+
185
+ def clear(self, environ, start_response):
186
+ data = self.form(environ)
187
+ if not mutation_allowed(environ, self.token, data.get("token", [""])[0]):
188
+ return self.response(start_response, "Forbidden", 403, "text/plain; charset=utf-8")
189
+ if not self.repository.available:
190
+ return self.storage_error(start_response)
191
+ try:
192
+ self.repository.clear()
193
+ except Exception as exc:
194
+ return self.storage_error(start_response, exc)
195
+ return self.redirect(start_response, self.base + "/")
196
+
197
+ def replay(self, exchange_id, environ, start_response):
198
+ data = self.form(environ)
199
+ if not mutation_allowed(environ, self.token, data.get("token", [""])[0]):
200
+ return self.response(start_response, "Forbidden", 403, "text/plain; charset=utf-8")
201
+ try:
202
+ exchange = self.repository.get_exchange(int(exchange_id))
203
+ except (TypeError, ValueError):
204
+ exchange = None
205
+ except Exception as exc:
206
+ return self.storage_error(start_response, exc)
207
+ if exchange is None:
208
+ return self.response(start_response, "Request not found", 404, "text/plain; charset=utf-8")
209
+ try:
210
+ attempt = replay_exchange(exchange, self.config, self.repository, allow_risky=True)
211
+ except Exception as exc:
212
+ return self.storage_error(start_response, exc)
213
+ message = "Replay completed." if attempt.state == ReplayAttemptRecord.State.COMPLETE else f"Replay failed: {attempt.error_summary}"
214
+ if attempt.persistence_error:
215
+ qualifier = "may have been sent" if attempt.network_attempted else "was not sent"
216
+ message = f"Replay {qualifier}, but its final result could not be saved: {attempt.persistence_error}"
217
+ return self.detail(exchange_id, start_response, message)
218
+
219
+ def edit_replay(self, exchange_id, environ, start_response):
220
+ try:
221
+ payload = self.read_json(environ)
222
+ except PayloadError as exc:
223
+ return self.json_error(start_response, exc.status, exc.code, exc.message)
224
+ if not mutation_allowed(environ, self.token, payload["token"]):
225
+ return self.json_error(start_response, 403, "forbidden", "Forbidden.")
226
+ try:
227
+ exchange = self.repository.get_exchange(int(exchange_id))
228
+ except (TypeError, ValueError):
229
+ exchange = None
230
+ except Exception as exc:
231
+ return self.json_error(start_response, 500, "storage_unavailable", str(exc))
232
+ if exchange is None:
233
+ return self.json_error(start_response, 404, "not_found", "Request not found.")
234
+ allowed, _, reason = editable_body(exchange)
235
+ if not allowed:
236
+ return self.json_error(start_response, 409, "body_not_editable", reason, field="body_text")
237
+ try:
238
+ headers = parse_headers(payload["headers_text"])
239
+ body = encode_edited_body(headers, payload["body_text"])
240
+ except EditValidationError as exc:
241
+ return self.json_error(start_response, exc.status, exc.code, exc.message, field=exc.field, line=exc.line)
242
+ try:
243
+ attempt = replay_exchange(
244
+ exchange, self.config, self.repository, allow_risky=True, headers=headers, body=body, mode="edited"
245
+ )
246
+ except Exception as exc:
247
+ return self.json_error(start_response, 500, "attempt_persistence_failed", str(exc))
248
+ attempt_data = self.attempt_json(attempt)
249
+ if attempt.persistence_error:
250
+ return self.json_error(
251
+ start_response, 500, "outcome_persistence_failed",
252
+ "The replay may have been sent, but its outcome could not be saved.",
253
+ attempt=attempt_data, request_may_have_been_sent=attempt.network_attempted,
254
+ )
255
+ if attempt.state == ReplayAttemptRecord.State.COMPLETE:
256
+ return self.json_response(start_response, {
257
+ "ok": True, "code": "replay_complete", "message": "Replay completed.", "attempt": attempt_data,
258
+ })
259
+ return self.json_response(start_response, {
260
+ "ok": False, "code": "replay_failed", "message": f"Replay failed: {attempt.error_summary}",
261
+ "attempt": attempt_data,
262
+ })
263
+
264
+ @staticmethod
265
+ def attempt_json(attempt):
266
+ return {
267
+ "id": attempt.id, "mode": attempt.mode, "state": attempt.state,
268
+ "response_status": attempt.response_status, "error_stage": attempt.error_stage,
269
+ "error_summary": attempt.error_summary,
270
+ }
271
+
272
+ def json_error(self, start_response, status, code, message, **extra):
273
+ payload = {"ok": False, "code": code, "message": message, "request_may_have_been_sent": False}
274
+ payload.update({key: value for key, value in extra.items() if value is not None})
275
+ return self.json_response(start_response, payload, status)
276
+
277
+ def storage_error(self, start_response, error=None):
278
+ detail = str(error or self.repository.error or "Unknown storage error")
279
+ body = (
280
+ "<!doctype html><html><head><title>Inspector storage unavailable</title></head>"
281
+ "<body><h1>Inspector storage unavailable</h1><p>Business requests continue normally.</p>"
282
+ f"<pre>{escape(detail)}</pre></body></html>"
283
+ )
284
+ return self.response(start_response, body, 500)
285
+
286
+ def asset(self, relative, start_response):
287
+ name = relative.rsplit("/", 1)[-1]
288
+ if name not in {"inspect.css", "inspect.js"}:
289
+ return self.response(start_response, "Not found", 404, "text/plain; charset=utf-8")
290
+ data = files("django_http_inspector").joinpath("static", "django_http_inspector", name).read_bytes()
291
+ content_type = "text/css; charset=utf-8" if name.endswith(".css") else "text/javascript; charset=utf-8"
292
+ return self.response(start_response, data, content_type=content_type)