httpdbg 2.3.0__tar.gz → 2.4.1__tar.gz

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 (99) hide show
  1. {httpdbg-2.3.0 → httpdbg-2.4.1}/PKG-INFO +1 -1
  2. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/__init__.py +1 -1
  3. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/export.py +2 -5
  4. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/external.py +50 -7
  5. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/record.py +82 -0
  6. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/recordhttp1.py +45 -0
  7. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/recordhttp2.py +59 -0
  8. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/template_sitecustomize.py +39 -10
  9. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/initiator.py +22 -0
  10. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/records.py +42 -0
  11. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/utils.py +4 -0
  12. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/app.py +2 -10
  13. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg.egg-info/PKG-INFO +1 -1
  14. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg.egg-info/SOURCES.txt +1 -1
  15. httpdbg-2.4.1/tests/test_serialization.py +82 -0
  16. httpdbg-2.3.0/httpdbg/webapp/api.py +0 -110
  17. {httpdbg-2.3.0 → httpdbg-2.4.1}/LICENSE +0 -0
  18. {httpdbg-2.3.0 → httpdbg-2.4.1}/README.md +0 -0
  19. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/__main__.py +0 -0
  20. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/args.py +0 -0
  21. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/env.py +0 -0
  22. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/exception.py +0 -0
  23. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/__init__.py +0 -0
  24. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/aiohttp.py +0 -0
  25. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/all.py +0 -0
  26. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/fastapi.py +0 -0
  27. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/flask.py +0 -0
  28. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/generic.py +0 -0
  29. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/h2.py +0 -0
  30. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/http.py +0 -0
  31. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/httpx.py +0 -0
  32. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/pytest.py +0 -0
  33. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/requests.py +0 -0
  34. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/socket.py +0 -0
  35. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/starlette.py +0 -0
  36. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/unittest.py +0 -0
  37. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/urllib3.py +0 -0
  38. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/utils.py +0 -0
  39. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/hooks/uvicorn.py +0 -0
  40. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/http_status_code.py +0 -0
  41. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/log.py +0 -0
  42. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/mode_console.py +0 -0
  43. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/mode_module.py +0 -0
  44. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/mode_script.py +0 -0
  45. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/preview.py +0 -0
  46. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/server.py +0 -0
  47. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/__init__.py +0 -0
  48. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/api.js +0 -0
  49. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/column.css +0 -0
  50. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/configuration.css +0 -0
  51. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/configuration.js +0 -0
  52. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/detail.css +0 -0
  53. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/favicon.ico +0 -0
  54. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/filter.js +0 -0
  55. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/chevron-direction-top-outline-icon.svg +0 -0
  56. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/download-install-line-icon.svg +0 -0
  57. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/engine-gears-icon.svg +0 -0
  58. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/eye-blind-icon.svg +0 -0
  59. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/eye-icon.svg +0 -0
  60. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/files-papers-outline-icon.svg +0 -0
  61. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/filter-outline-icon.svg +0 -0
  62. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/lock-line-icon.svg +0 -0
  63. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/lock-open-line-icon.svg +0 -0
  64. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/math-multiplication-icon.svg +0 -0
  65. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/push-pin-green-icon.svg +0 -0
  66. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/question-mark-round-line-icon.svg +0 -0
  67. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/recycle-bin-line-icon.svg +0 -0
  68. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/icons/wait-sandclock-icon.svg +0 -0
  69. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/index.htm +0 -0
  70. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/main.js +0 -0
  71. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/mustache.min.js +0 -0
  72. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/render.js +0 -0
  73. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/style.css +0 -0
  74. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg/webapp/static/table.css +0 -0
  75. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg.egg-info/dependency_links.txt +0 -0
  76. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg.egg-info/entry_points.txt +0 -0
  77. {httpdbg-2.3.0 → httpdbg-2.4.1}/httpdbg.egg-info/top_level.txt +0 -0
  78. {httpdbg-2.3.0 → httpdbg-2.4.1}/pyproject.toml +0 -0
  79. {httpdbg-2.3.0 → httpdbg-2.4.1}/setup.cfg +0 -0
  80. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_api.py +0 -0
  81. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_cli.py +0 -0
  82. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_context_manager.py +0 -0
  83. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_aiohttp.py +0 -0
  84. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_h2.py +0 -0
  85. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_httpx.py +0 -0
  86. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_pytest.py +0 -0
  87. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_requests.py +0 -0
  88. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_unittest.py +0 -0
  89. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_hook_urllib3.py +0 -0
  90. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_initiator.py +0 -0
  91. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_mode_console.py +0 -0
  92. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_mode_module_pytest.py +0 -0
  93. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_mode_script.py +0 -0
  94. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_multiprocess.py +0 -0
  95. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_preview.py +0 -0
  96. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_pyhttpdbg_server.py +0 -0
  97. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_server.py +0 -0
  98. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_server_hook_fastapi.py +0 -0
  99. {httpdbg-2.3.0 → httpdbg-2.4.1}/tests/test_server_hook_flask.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpdbg
3
- Version: 2.3.0
3
+ Version: 2.4.1
4
4
  Summary: A very simple tool to debug HTTP(S) client and server requests.
5
5
  Author-email: cle-b <cle@tictac.pm>
6
6
  License-Expression: Apache-2.0
@@ -1,4 +1,4 @@
1
- __version__ = "2.3.0"
1
+ __version__ = "2.4.1"
2
2
 
3
3
  __all__ = ["export_html", "httprecord", "HTTPRecords"]
4
4
 
@@ -6,7 +6,6 @@ import xml.etree.ElementTree as ET
6
6
 
7
7
  import httpdbg
8
8
  from httpdbg import HTTPRecords
9
- from httpdbg.webapp.api import RequestListPayload, RequestPayload
10
9
 
11
10
 
12
11
  def generate_html(records: HTTPRecords, for_export: bool = True) -> str:
@@ -67,12 +66,10 @@ def generate_html(records: HTTPRecords, for_export: bool = True) -> str:
67
66
 
68
67
  # static export of the requests data
69
68
  if for_export:
70
- static_all_requests = json.dumps(
71
- records, cls=RequestListPayload, ensure_ascii=False
72
- )
69
+ static_all_requests = records.payload.decode("ascii")
73
70
  map_requests: dict[str, object] = dict()
74
71
  for record in records:
75
- map_requests[record.id] = json.loads(json.dumps(record, cls=RequestPayload))
72
+ map_requests[record.id] = json.loads(record.payload.decode("ascii"))
76
73
  static_requests: str = json.dumps(map_requests, ensure_ascii=False)
77
74
 
78
75
  def safe_for_script_tag(s: str) -> str:
@@ -1,17 +1,31 @@
1
1
  from collections.abc import Generator
2
2
  from contextlib import contextmanager
3
+ import json
3
4
  import glob
4
5
  import os
5
- import pickle
6
6
  import tempfile
7
7
  import time
8
8
  import threading
9
9
 
10
10
  from httpdbg.env import HTTPDBG_MULTIPROCESS_DIR
11
+ from httpdbg.hooks.recordhttp1 import HTTP1Record
12
+ from httpdbg.hooks.recordhttp2 import HTTP2Record
13
+ from httpdbg.initiator import Group
14
+ from httpdbg.initiator import Initiator
11
15
  from httpdbg.log import logger
12
16
  from httpdbg.records import HTTPRecords
13
17
 
14
18
 
19
+ def _safe_delete_file(filename):
20
+ try:
21
+ os.remove(filename)
22
+ except Exception as ex:
23
+ logger().info(
24
+ f"_safe_delete_file - silently_catch_error - {str(ex)}",
25
+ exc_info=True,
26
+ )
27
+
28
+
15
29
  @contextmanager
16
30
  def watcher_external(
17
31
  records: HTTPRecords,
@@ -77,12 +91,41 @@ class WatcherSubprocessDirThread(threading.Thread):
77
91
  threading.Thread.__init__(self)
78
92
 
79
93
  def load_dump(self):
80
- for dump in glob.glob(os.path.join(self.directory, "*.httpdbgrecords")):
81
- with open(dump, "rb") as dumpfile:
82
- newrecords: HTTPRecords = pickle.load(dumpfile)
83
- self.records.requests.update(newrecords.requests)
84
- self.records.initiators.update(newrecords.initiators)
85
- self.records.groups.update(newrecords.groups)
94
+ # initiators
95
+ for filename in glob.glob(os.path.join(self.directory, "*.httpdbg-initiator")):
96
+ with open(filename, "r") as f:
97
+ data = json.load(f)
98
+ initiator = Initiator.from_json(data)
99
+ self.records.initiators[initiator.id] = initiator
100
+ _safe_delete_file(filename)
101
+
102
+ # group
103
+ for filename in glob.glob(os.path.join(self.directory, "*.httpdbg-group")):
104
+ with open(filename, "r") as f:
105
+ data = json.load(f)
106
+ group = Group.from_json(data)
107
+ self.records.groups[group.id] = group
108
+ _safe_delete_file(filename)
109
+
110
+ # httprecord HTTP1
111
+ for filename in sorted(
112
+ glob.glob(os.path.join(self.directory, "*.httpdbg-http1record"))
113
+ ):
114
+ with open(filename, "r") as f:
115
+ data = json.load(f)
116
+ http1record = HTTP1Record.from_json(data)
117
+ self.records.requests[http1record.id] = http1record
118
+ _safe_delete_file(filename)
119
+
120
+ # httprecord HTTP2
121
+ for filename in sorted(
122
+ glob.glob(os.path.join(self.directory, "*.httpdbg-http2record"))
123
+ ):
124
+ with open(filename, "r") as f:
125
+ data = json.load(f)
126
+ http2record = HTTP2Record.from_json(data)
127
+ self.records.requests[http2record.id] = http2record
128
+ _safe_delete_file(filename)
86
129
 
87
130
  def run(self):
88
131
  while self._running:
@@ -1,5 +1,8 @@
1
1
  from abc import ABC, abstractmethod
2
2
  import datetime
3
+ import json
4
+ import traceback
5
+ from typing import Any
3
6
  from urllib.parse import urlparse
4
7
 
5
8
  from httpdbg.utils import get_new_uuid
@@ -176,3 +179,82 @@ class HTTPRecord(ABC):
176
179
  return self.request.protocol
177
180
  else:
178
181
  return f"{self.request.protocol} -> {self.response.protocol}"
182
+
183
+ @property
184
+ def payload(self) -> bytes:
185
+ payload: dict[str, Any] = {
186
+ "id": self.id,
187
+ "url": self.url,
188
+ "netloc": self.netloc,
189
+ "urlext": self.urlext,
190
+ "method": self.method,
191
+ "status_code": self.status_code,
192
+ "http100": self.http100,
193
+ "reason": self.reason,
194
+ "request": None,
195
+ "response": None,
196
+ "exception": None,
197
+ "tag": self.tag,
198
+ "initiator_id": self.initiator_id,
199
+ "group_id": self.group_id,
200
+ "in_progress": self.in_progress,
201
+ "is_server": not self.is_client,
202
+ }
203
+
204
+ payload["request"] = {
205
+ "headers": [header.to_json() for header in self.request.headers],
206
+ "cookies": [cookie.to_json() for cookie in self.request.cookies],
207
+ }
208
+
209
+ if self.request.content:
210
+ payload["request"]["body"] = {
211
+ "path": f"/request/{self.id}/up",
212
+ "filename": "upload",
213
+ }
214
+ payload["request"]["body"].update(self.request.preview)
215
+
216
+ payload["response"] = {
217
+ "headers": [header.to_json() for header in self.response.headers],
218
+ "cookies": [cookie.to_json() for cookie in self.response.cookies],
219
+ }
220
+
221
+ if self.response.content:
222
+ payload["response"]["body"] = {
223
+ "path": f"/request/{self.id}/down",
224
+ "filename": "download",
225
+ }
226
+ payload["response"]["body"].update(self.response.preview)
227
+
228
+ if self.exception is not None:
229
+ payload["exception"] = {
230
+ "type": str(type(self.exception)),
231
+ "message": "".join(
232
+ traceback.format_exception(
233
+ type(self.exception),
234
+ self.exception,
235
+ self.exception.__traceback__,
236
+ )
237
+ ),
238
+ }
239
+
240
+ return json.dumps(payload).encode("utf-8")
241
+
242
+ def __eq__(self, other: object) -> bool:
243
+ if not isinstance(other, HTTPRecord):
244
+ return False
245
+
246
+ return (
247
+ self.url == other.url
248
+ and self.method == other.method
249
+ and self.status_code == other.status_code
250
+ and self.protocol == other.protocol
251
+ and self.tag == other.tag
252
+ and self.initiator_id == other.initiator_id
253
+ and self.group_id == other.group_id
254
+ and self.is_client == other.is_client
255
+ and self.http100 == other.http100
256
+ and type(self.exception) is type(other.exception)
257
+ and str(self.exception) == str(other.exception)
258
+ and self.request == other.request
259
+ and self.response == other.response
260
+ )
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import datetime
2
3
 
3
4
  from httpdbg.preview import generate_preview
@@ -76,6 +77,12 @@ class HTTP1RecordReqResp(HTTPRecordReqResp):
76
77
  self.last_update = datetime.datetime.now(datetime.timezone.utc)
77
78
  self._rawdata = value
78
79
 
80
+ def __eq__(self, other: object) -> bool:
81
+ if not isinstance(other, HTTP1RecordReqResp):
82
+ return False
83
+
84
+ return self.rawdata == other.rawdata and self.last_update == other.last_update
85
+
79
86
 
80
87
  class HTTP1RecordRequest(HTTPRecordRequest, HTTP1RecordReqResp):
81
88
  def __init__(self) -> None:
@@ -195,3 +202,41 @@ class HTTP1Record(HTTPRecord):
195
202
  self.request.rawdata += data
196
203
  else:
197
204
  self.response.rawdata += data
205
+
206
+ def to_json(self) -> dict:
207
+ return {
208
+ "id": self.id,
209
+ "initiator_id": self.initiator_id,
210
+ "group_id": self.group_id,
211
+ "tag": self.tag,
212
+ "tbegin": self.tbegin.isoformat(),
213
+ "is_client": self.is_client,
214
+ "received": base64.b64encode(
215
+ self.response.rawdata if self.is_client else self.request.rawdata
216
+ ).decode("ascii"),
217
+ "sent": base64.b64encode(
218
+ self.request.rawdata if self.is_client else self.response.rawdata,
219
+ ).decode("ascii"),
220
+ "last_update_request": self.request.last_update.isoformat(),
221
+ "last_update_response": self.response.last_update.isoformat(),
222
+ }
223
+
224
+ @staticmethod
225
+ def from_json(data: dict) -> "HTTP1Record":
226
+ http1record = HTTP1Record(
227
+ data["initiator_id"],
228
+ data["group_id"],
229
+ data["tag"],
230
+ datetime.datetime.fromisoformat(data["tbegin"]),
231
+ data["is_client"],
232
+ )
233
+ http1record.id = data["id"]
234
+ http1record.send_data(base64.b64decode(data["sent"]))
235
+ http1record.receive_data(base64.b64decode(data["received"]))
236
+ http1record.request.last_update = datetime.datetime.fromisoformat(
237
+ data["last_update_request"]
238
+ )
239
+ http1record.response.last_update = datetime.datetime.fromisoformat(
240
+ data["last_update_response"]
241
+ )
242
+ return http1record
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import datetime
2
3
 
3
4
  from httpdbg.preview import generate_preview
@@ -41,6 +42,16 @@ class HTTP2RecordReqResp(HTTPRecordReqResp):
41
42
  self.get_header("Content-Encoding"),
42
43
  )
43
44
 
45
+ def __eq__(self, other: object) -> bool:
46
+ if not isinstance(other, HTTP2RecordReqResp):
47
+ return False
48
+
49
+ return (
50
+ self.headers == other.headers
51
+ and self.content == other.content
52
+ and self.last_update == other.last_update
53
+ )
54
+
44
55
 
45
56
  class HTTP2RecordRequest(HTTPRecordRequest, HTTP2RecordReqResp):
46
57
 
@@ -111,3 +122,51 @@ class HTTP2Record(HTTPRecord):
111
122
  self.request.content += data
112
123
  else:
113
124
  self.response.content += data
125
+
126
+ def to_json(self) -> dict:
127
+ return {
128
+ "id": self.id,
129
+ "initiator_id": self.initiator_id,
130
+ "group_id": self.group_id,
131
+ "tag": self.tag,
132
+ "tbegin": self.tbegin.isoformat(),
133
+ "is_client": self.is_client,
134
+ "request": {
135
+ "headers": [header.to_json() for header in self.request.headers],
136
+ "content": base64.b64encode(self.request.content).decode("ascii"),
137
+ },
138
+ "response": {
139
+ "headers": [header.to_json() for header in self.response.headers],
140
+ "content": base64.b64encode(self.response.content).decode("ascii"),
141
+ },
142
+ "last_update_request": self.request.last_update.isoformat(),
143
+ "last_update_response": self.response.last_update.isoformat(),
144
+ }
145
+
146
+ @staticmethod
147
+ def from_json(data: dict) -> "HTTP2Record":
148
+ http2record = HTTP2Record(
149
+ data["initiator_id"],
150
+ data["group_id"],
151
+ data["tag"],
152
+ datetime.datetime.fromisoformat(data["tbegin"]),
153
+ data["is_client"],
154
+ )
155
+ http2record.id = data["id"]
156
+ http2record.request._headers = [
157
+ HTTPDBGHeader.from_json(header) for header in data["request"]["headers"]
158
+ ]
159
+ http2record.request.content = base64.b64decode(data["request"]["content"])
160
+ http2record.request.last_update = datetime.datetime.fromisoformat(
161
+ data["last_update_request"]
162
+ )
163
+
164
+ http2record.response._headers = [
165
+ HTTPDBGHeader.from_json(header) for header in data["response"]["headers"]
166
+ ]
167
+ http2record.response.content = base64.b64decode(data["response"]["content"])
168
+ http2record.response.last_update = datetime.datetime.fromisoformat(
169
+ data["last_update_response"]
170
+ )
171
+
172
+ return http2record
@@ -1,6 +1,6 @@
1
1
  import atexit
2
+ import json
2
3
  import os
3
- import pickle
4
4
  import sys
5
5
  import time
6
6
  import threading
@@ -9,12 +9,22 @@ import uuid
9
9
  from httpdbg import HTTPRecords
10
10
  from httpdbg.env import HTTPDBG_MULTIPROCESS_DIR
11
11
  from httpdbg.hooks.all import httprecord
12
+ from httpdbg.hooks.record import HTTPRecord
13
+ from httpdbg.hooks.recordhttp1 import HTTP1Record
14
+ from httpdbg.hooks.recordhttp2 import HTTP2Record
12
15
  from httpdbg.log import logger
13
16
 
14
17
  HTTPDBG_INITIATORS = [] # type: ignore
15
18
  HTTPDBG_RECORD_SERVER = False
16
19
 
17
20
 
21
+ def _safe_write_json_file(filename, content):
22
+ with open(f"{filename}.tmp", "wb") as f:
23
+ f.write(json.dumps(content).encode("utf-8"))
24
+
25
+ os.replace(f"{filename}.tmp", filename)
26
+
27
+
18
28
  class HttpdbgRecorder:
19
29
  """Records the HTTP requests in subprocesses and saves them to disk."""
20
30
 
@@ -23,8 +33,7 @@ class HttpdbgRecorder:
23
33
  self.fname: str = os.path.join(
24
34
  os.environ[HTTPDBG_MULTIPROCESS_DIR], str(uuid.uuid1())
25
35
  )
26
- self.context = None
27
- self.records: HTTPRecords | None = None
36
+ self.serialized: list[str] = []
28
37
 
29
38
  def _save_to_disk_loop(self):
30
39
  while self._running:
@@ -44,19 +53,39 @@ class HttpdbgRecorder:
44
53
  time.sleep(1)
45
54
 
46
55
  def save_to_disk(self):
47
- if self.records and len(self.records.requests) > 0:
48
- with open(f"{self.fname}.httpdbgrecords.tmp", "wb") as f:
49
- pickle.dump(self.records, f)
50
- os.replace(
51
- f"{self.fname}.httpdbgrecords.tmp", f"{self.fname}.httpdbgrecords"
52
- )
56
+ # initiators
57
+ for initiator in self.records.initiators.values():
58
+ if initiator.id not in self.serialized:
59
+ self.serialized.append(initiator.id)
60
+ filename = f"{self.fname}.{initiator.id}.httpdbg-initiator"
61
+ _safe_write_json_file(filename, initiator.to_json())
62
+
63
+ # groups
64
+ for group in self.records.groups.values():
65
+ if group.id not in self.serialized:
66
+ self.serialized.append(group.id)
67
+ filename = f"{self.fname}.{group.id}.httpdbg-group"
68
+ _safe_write_json_file(filename, group.to_json())
69
+
70
+ # requests
71
+ for request in self.records.requests.values():
72
+ if isinstance(request, HTTPRecord):
73
+ marker = f"{request.id}-{request.last_update.isoformat()}"
74
+ if marker not in self.serialized:
75
+ self.serialized.append(marker)
76
+ if isinstance(request, HTTP1Record):
77
+ filename = f"{self.fname}.{marker}.httpdbg-http1record"
78
+ _safe_write_json_file(filename, request.to_json())
79
+ elif isinstance(request, HTTP2Record):
80
+ filename = f"{self.fname}.{marker}.httpdbg-http2record"
81
+ _safe_write_json_file(filename, request.to_json())
53
82
 
54
83
  def start(self):
55
84
  """Start recording."""
56
85
  self.context = httprecord(
57
86
  initiators=HTTPDBG_INITIATORS, server=HTTPDBG_RECORD_SERVER
58
87
  )
59
- self.records = self.context.__enter__()
88
+ self.records: HTTPRecords = self.context.__enter__()
60
89
  self._running = True
61
90
  self._thread = threading.Thread(target=self._save_to_disk_loop, daemon=True)
62
91
  self._thread.start()
@@ -59,6 +59,17 @@ class Initiator:
59
59
  }
60
60
  return json
61
61
 
62
+ @staticmethod
63
+ def from_json(data: dict) -> "Initiator":
64
+ initiator = Initiator(
65
+ data["label"],
66
+ data["short_stack"],
67
+ data["stack"].split("\n"),
68
+ )
69
+ initiator.id = data["id"]
70
+ initiator.tbegin = datetime.datetime.fromisoformat(data["tbegin"])
71
+ return initiator
72
+
62
73
 
63
74
  class Group:
64
75
  def __init__(self, label: str, full_label: str, updatable: bool):
@@ -76,6 +87,17 @@ class Group:
76
87
  "tbegin": self.tbegin.isoformat(),
77
88
  }
78
89
 
90
+ @staticmethod
91
+ def from_json(data: dict) -> "Group":
92
+ group = Group(
93
+ data["label"],
94
+ data["full_label"],
95
+ False,
96
+ )
97
+ group.id = data["id"]
98
+ group.tbegin = datetime.datetime.fromisoformat(data["tbegin"])
99
+ return group
100
+
79
101
 
80
102
  def compatible_path(path: str) -> str:
81
103
  p = path
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ import json
2
3
  import sys
3
4
 
4
5
  from httpdbg.hooks.record import HTTPRecord
@@ -89,3 +90,44 @@ class HTTPRecords:
89
90
  print(f"+ {request.url}")
90
91
  print(f" - initiator: {self.initiators[request.initiator_id].label}")
91
92
  print(f" - group: {self.groups[request.group_id].label}")
93
+
94
+ @property
95
+ def payload(self) -> bytes:
96
+ payload: dict[str, dict[str, str | dict]] = {
97
+ "session": {
98
+ "id": self.session.id,
99
+ "command_line": self.session.command_line,
100
+ "tbegin": self.session.tbegin.isoformat(),
101
+ },
102
+ "requests": {},
103
+ "initiators": {},
104
+ "groups": {},
105
+ }
106
+
107
+ for id, req in self.requests.items():
108
+ payload["requests"][id] = {
109
+ "id": req.id,
110
+ "url": req.url,
111
+ "netloc": req.netloc,
112
+ "urlext": req.urlext,
113
+ "status_code": req.status_code,
114
+ "http100": req.http100,
115
+ "protocol": req.protocol,
116
+ "reason": req.reason,
117
+ "verb": req.method,
118
+ "tag": req.tag,
119
+ "initiator_id": req.initiator_id,
120
+ "group_id": req.group_id,
121
+ "in_progress": req.in_progress,
122
+ "is_server": not req.is_client,
123
+ "tbegin": req.tbegin.isoformat(),
124
+ "last_update": req.last_update.isoformat(),
125
+ }
126
+
127
+ for id, initiator in self.initiators.items():
128
+ payload["initiators"][id] = initiator.to_json(full=True)
129
+
130
+ for id, group in self.groups.items():
131
+ payload["groups"][id] = group.to_json()
132
+
133
+ return json.dumps(payload).encode("utf-8")
@@ -66,6 +66,10 @@ class HTTPDBGHeader(object):
66
66
  def to_json(self) -> dict:
67
67
  return {"name": self.name, "value": self.value}
68
68
 
69
+ @staticmethod
70
+ def from_json(data) -> "HTTPDBGHeader":
71
+ return HTTPDBGHeader(data["name"], data["value"])
72
+
69
73
  def __eq__(self, other) -> bool:
70
74
  if type(other) is HTTPDBGHeader:
71
75
  return (self.name == other.name) and (self.value == other.value)
@@ -1,7 +1,6 @@
1
1
  from collections.abc import Callable
2
2
  from contextlib import contextmanager
3
3
  from http.server import BaseHTTPRequestHandler
4
- import json
5
4
  from pathlib import Path
6
5
  import re
7
6
  from urllib.parse import ParseResult
@@ -9,7 +8,6 @@ from urllib.parse import parse_qs
9
8
  from urllib.parse import urlparse
10
9
 
11
10
  from httpdbg.log import logger
12
- from httpdbg.webapp.api import RequestListPayload, RequestPayload
13
11
  from httpdbg.records import HTTPRecords
14
12
 
15
13
 
@@ -97,9 +95,7 @@ class HttpbgHTTPRequestHandler(BaseHTTPRequestHandler):
97
95
  self.send_header("Content-type", "application/json")
98
96
  self.send_header_no_cache()
99
97
  self.end_headers()
100
- self.wfile.write(
101
- json.dumps(self.records, cls=RequestListPayload).encode("utf-8")
102
- )
98
+ self.wfile.write(self.records.payload)
103
99
 
104
100
  return True
105
101
 
@@ -118,11 +114,7 @@ class HttpbgHTTPRequestHandler(BaseHTTPRequestHandler):
118
114
  self.send_header("Content-type", "application/json")
119
115
  self.send_header_no_cache()
120
116
  self.end_headers()
121
- self.wfile.write(
122
- json.dumps(self.records.requests[req_id], cls=RequestPayload).encode(
123
- "utf-8"
124
- )
125
- )
117
+ self.wfile.write(self.records.requests[req_id].payload)
126
118
 
127
119
  return True
128
120
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpdbg
3
- Version: 2.3.0
3
+ Version: 2.4.1
4
4
  Summary: A very simple tool to debug HTTP(S) client and server requests.
5
5
  Author-email: cle-b <cle@tictac.pm>
6
6
  License-Expression: Apache-2.0
@@ -46,7 +46,6 @@ httpdbg/hooks/urllib3.py
46
46
  httpdbg/hooks/utils.py
47
47
  httpdbg/hooks/uvicorn.py
48
48
  httpdbg/webapp/__init__.py
49
- httpdbg/webapp/api.py
50
49
  httpdbg/webapp/app.py
51
50
  httpdbg/webapp/static/api.js
52
51
  httpdbg/webapp/static/column.css
@@ -92,6 +91,7 @@ tests/test_mode_script.py
92
91
  tests/test_multiprocess.py
93
92
  tests/test_preview.py
94
93
  tests/test_pyhttpdbg_server.py
94
+ tests/test_serialization.py
95
95
  tests/test_server.py
96
96
  tests/test_server_hook_fastapi.py
97
97
  tests/test_server_hook_flask.py
@@ -0,0 +1,82 @@
1
+ import httpx
2
+ import pytest
3
+ import requests
4
+
5
+ from httpdbg.hooks.all import httprecord
6
+ from httpdbg.hooks.recordhttp1 import HTTP1Record
7
+ from httpdbg.hooks.recordhttp2 import HTTP2Record
8
+ from httpdbg.utils import HTTPDBGCookie
9
+ from httpdbg.utils import HTTPDBGHeader
10
+
11
+
12
+ from tests.demo_http2 import http2_httpbin_server
13
+ from tests.utils import get_free_tcp_port
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def httpbin2():
18
+ host = "127.0.0.1"
19
+ port = get_free_tcp_port(host)
20
+ with http2_httpbin_server(host, port) as url:
21
+ yield url
22
+
23
+
24
+ @pytest.mark.serialization
25
+ def test_serialization_http1record(httpbin):
26
+ with httprecord() as records:
27
+ requests.get(
28
+ f"{httpbin.url}/cookies/set/confiture/oignon",
29
+ cookies={"jam": "strawberry"},
30
+ allow_redirects=False,
31
+ )
32
+
33
+ assert len(records) == 1
34
+ http_record = records[0]
35
+ http_record_copy = HTTP1Record.from_json(http_record.to_json())
36
+
37
+ assert HTTPDBGCookie("jam", "strawberry") in http_record_copy.request.cookies
38
+
39
+ assert (
40
+ HTTPDBGCookie(
41
+ "confiture",
42
+ "oignon",
43
+ [
44
+ {"attr": "/", "name": "path"},
45
+ ],
46
+ )
47
+ in http_record_copy.response.cookies
48
+ )
49
+
50
+ assert http_record_copy == http_record
51
+
52
+
53
+ @pytest.mark.serialization
54
+ def test_serialization_http2record(httpbin2):
55
+ with httprecord() as records:
56
+ client = httpx.Client(http2=True, verify=False)
57
+ client.post(f"{httpbin2}/post", content="abc")
58
+
59
+ assert len(records) == 1
60
+ http_record = records[0]
61
+ http_record_copy = HTTP2Record.from_json(http_record.to_json())
62
+
63
+ assert http_record_copy.protocol == "HTTP/2"
64
+
65
+ assert http_record_copy.method.upper() == "POST"
66
+ assert http_record_copy.status_code == 200
67
+
68
+ assert HTTPDBGHeader("content-length", "3") in http_record_copy.request.headers
69
+ assert http_record_copy.request.cookies == []
70
+ assert http_record_copy.request.content == b"abc"
71
+
72
+ assert http_record_copy.last_update == http_record.last_update
73
+
74
+ assert http_record_copy == http_record
75
+
76
+
77
+ @pytest.mark.serialization
78
+ def test_serialization_compatibility():
79
+ http1record = HTTP1Record("a", "b")
80
+ http2record = HTTP2Record("a", "b")
81
+ assert not (http1record == http2record)
82
+ assert not (http2record == http1record)
@@ -1,110 +0,0 @@
1
- from json import JSONEncoder
2
- import traceback
3
- from typing import Any
4
-
5
- from httpdbg.records import HTTPRecords
6
- from httpdbg.records import HTTPRecord
7
-
8
-
9
- class RequestPayload(JSONEncoder):
10
- def default(self, req: HTTPRecord) -> dict[str, Any]:
11
- payload: dict[str, Any] = {
12
- "id": req.id,
13
- "url": req.url,
14
- "netloc": req.netloc,
15
- "urlext": req.urlext,
16
- "method": req.method,
17
- "status_code": req.status_code,
18
- "http100": req.http100,
19
- "reason": req.reason,
20
- "request": None,
21
- "response": None,
22
- "exception": None,
23
- "tag": req.tag,
24
- "initiator_id": req.initiator_id,
25
- "group_id": req.group_id,
26
- "in_progress": req.in_progress,
27
- "is_server": not req.is_client,
28
- }
29
-
30
- payload["request"] = {
31
- "headers": [header.to_json() for header in req.request.headers],
32
- "cookies": [cookie.to_json() for cookie in req.request.cookies],
33
- }
34
-
35
- if req.request.content:
36
- payload["request"]["body"] = {
37
- "path": f"/request/{req.id}/up",
38
- "filename": "upload",
39
- }
40
- payload["request"]["body"].update(req.request.preview)
41
-
42
- payload["response"] = {
43
- "headers": [header.to_json() for header in req.response.headers],
44
- "cookies": [cookie.to_json() for cookie in req.response.cookies],
45
- }
46
-
47
- if req.response.content:
48
- payload["response"]["body"] = {
49
- "path": f"/request/{req.id}/down",
50
- "filename": "download",
51
- }
52
- payload["response"]["body"].update(req.response.preview)
53
-
54
- if req.exception is not None:
55
- payload["exception"] = {
56
- "type": str(type(req.exception)),
57
- "message": "".join(
58
- traceback.format_exception(
59
- type(req.exception), req.exception, req.exception.__traceback__
60
- )
61
- ),
62
- }
63
-
64
- return payload
65
-
66
-
67
- class RequestListPayload(JSONEncoder):
68
- def default(self, records: HTTPRecords):
69
- assert isinstance(
70
- records, HTTPRecords
71
- ), "This encoder works only for HTTPRecords object."
72
-
73
- payload: dict[str, dict[str, str | dict]] = {
74
- "session": {
75
- "id": records.session.id,
76
- "command_line": records.session.command_line,
77
- "tbegin": records.session.tbegin.isoformat(),
78
- },
79
- "requests": {},
80
- "initiators": {},
81
- "groups": {},
82
- }
83
-
84
- for id, req in records.requests.items():
85
- payload["requests"][id] = {
86
- "id": req.id,
87
- "url": req.url,
88
- "netloc": req.netloc,
89
- "urlext": req.urlext,
90
- "status_code": req.status_code,
91
- "http100": req.http100,
92
- "protocol": req.protocol,
93
- "reason": req.reason,
94
- "verb": req.method,
95
- "tag": req.tag,
96
- "initiator_id": req.initiator_id,
97
- "group_id": req.group_id,
98
- "in_progress": req.in_progress,
99
- "is_server": not req.is_client,
100
- "tbegin": req.tbegin.isoformat(),
101
- "last_update": req.last_update.isoformat(),
102
- }
103
-
104
- for id, initiator in records.initiators.items():
105
- payload["initiators"][id] = initiator.to_json(full=True)
106
-
107
- for id, group in records.groups.items():
108
- payload["groups"][id] = group.to_json()
109
-
110
- return payload
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes