pypproxy 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.
Files changed (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from nicegui import ui
6
+
7
+ from pypproxy.store.models import Entry
8
+ from pypproxy.store.store import Store
9
+
10
+
11
+ def build_graphql_tab(store: Store) -> dict:
12
+ """Build the GraphQL tab. Returns state with open_entry method."""
13
+ state: dict = {"entry": None, "schema": None}
14
+
15
+ with ui.column().classes("w-full h-full overflow-auto q-pa-md"):
16
+ # --- Introspection section ---
17
+ with ui.expansion("Schema Introspection", icon="schema", value=True).classes(
18
+ "w-full q-mb-md"
19
+ ):
20
+ ui.label("Fetch the GraphQL schema from an endpoint using introspection.").classes(
21
+ "text-caption text-grey q-mb-sm"
22
+ )
23
+
24
+ with ui.row().classes("gap-2 items-center w-full"):
25
+ url_input = (
26
+ ui.input(
27
+ label="GraphQL endpoint URL", placeholder="https://api.example.com/graphql"
28
+ )
29
+ .props("dense outlined dark")
30
+ .classes("flex-1")
31
+ )
32
+ introspect_btn = ui.button("Introspect", icon="search").props(
33
+ "color=primary size=sm"
34
+ )
35
+
36
+ schema_status = ui.label("").classes("text-caption text-grey q-mt-xs")
37
+
38
+ schema_tree = (
39
+ ui.tree(
40
+ [],
41
+ label_key="label",
42
+ children_key="children",
43
+ )
44
+ .props("dark dense")
45
+ .classes("w-full q-mt-sm")
46
+ )
47
+
48
+ async def _introspect() -> None:
49
+ url = url_input.value.strip()
50
+ if not url:
51
+ ui.notify("Enter a URL", type="warning")
52
+ return
53
+ introspect_btn.props("loading")
54
+ try:
55
+ from pypproxy.graphql.introspection import fetch_schema
56
+
57
+ req_headers: dict[str, str] = {}
58
+ entry = state.get("entry")
59
+ if entry:
60
+ req_headers = {
61
+ k: ", ".join(v)
62
+ for k, v in entry.req_headers.items()
63
+ if k.lower() not in ("content-length",)
64
+ }
65
+
66
+ schema = await fetch_schema(url, req_headers)
67
+ if schema is None:
68
+ ui.notify("Introspection failed or not supported", type="negative")
69
+ return
70
+
71
+ state["schema"] = schema
72
+ schema_status.text = (
73
+ f"Schema loaded: {len(schema.types)} types, "
74
+ f"Query: {schema.query_type}, Mutation: {schema.mutation_type}"
75
+ )
76
+
77
+ # Build tree
78
+ nodes = []
79
+ for t in schema.types:
80
+ if t.name.startswith("__") or not t.fields:
81
+ continue
82
+ children = [
83
+ {"id": f"{t.name}.{f.name}", "label": f"{f.name}: {f.type_name}"}
84
+ for f in t.fields
85
+ ]
86
+ nodes.append(
87
+ {
88
+ "id": t.name,
89
+ "label": f"{t.name} ({t.kind})",
90
+ "children": children,
91
+ }
92
+ )
93
+ schema_tree.nodes = nodes
94
+ schema_tree.update()
95
+ ui.notify(f"Schema loaded: {len(schema.types)} types", type="positive")
96
+ finally:
97
+ introspect_btn.props(remove="loading")
98
+
99
+ introspect_btn.on("click", _introspect)
100
+
101
+ # --- Query editor ---
102
+ with ui.expansion("Query Editor", icon="code", value=True).classes("w-full q-mb-md"):
103
+ entry_label = ui.label("No entry selected").classes("text-caption text-grey q-mb-xs")
104
+
105
+ with ui.row().classes("gap-2 items-center q-mb-xs"):
106
+ op_type_badge = ui.badge("", color="grey").props("rounded")
107
+ op_name_label = ui.label("").classes("text-caption")
108
+
109
+ query_input = (
110
+ ui.textarea(placeholder="query { ... }\n\nOr paste a GraphQL query here")
111
+ .props("outlined dense rows=8")
112
+ .classes("w-full font-mono text-xs")
113
+ )
114
+
115
+ ui.label("Variables (JSON):").classes("text-caption q-mt-xs")
116
+ vars_input = (
117
+ ui.textarea(placeholder='{"id": "123"}')
118
+ .props("outlined dense rows=3")
119
+ .classes("w-full font-mono text-xs")
120
+ )
121
+
122
+ run_btn = ui.button("Run Query", icon="play_arrow").props("color=primary")
123
+ result_label = ui.label("").classes("text-caption text-grey q-mt-xs")
124
+ result_area = (
125
+ ui.textarea()
126
+ .props("outlined dense rows=10 readonly")
127
+ .classes("w-full font-mono text-xs q-mt-xs")
128
+ )
129
+
130
+ async def _run_query() -> None:
131
+ entry = state.get("entry")
132
+ if not entry:
133
+ ui.notify(
134
+ "No entry selected — right-click a GraphQL request in Traffic",
135
+ type="warning",
136
+ )
137
+ return
138
+
139
+ import httpx
140
+
141
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
142
+ req_headers = {
143
+ k: ", ".join(v)
144
+ for k, v in entry.req_headers.items()
145
+ if k.lower() not in ("content-length",)
146
+ }
147
+
148
+ query = query_input.value.strip()
149
+ if not query:
150
+ ui.notify("Enter a query", type="warning")
151
+ return
152
+
153
+ variables: dict = {}
154
+ if vars_input.value.strip():
155
+ try:
156
+ variables = json.loads(vars_input.value)
157
+ except Exception:
158
+ ui.notify("Variables must be valid JSON", type="warning")
159
+ return
160
+
161
+ run_btn.props("loading")
162
+ try:
163
+ async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
164
+ resp = await client.post(
165
+ url,
166
+ json={"query": query, "variables": variables},
167
+ headers=req_headers,
168
+ )
169
+ try:
170
+ body = json.dumps(resp.json(), indent=2, ensure_ascii=False)
171
+ except Exception:
172
+ body = resp.text
173
+ result_label.text = f"Status: {resp.status_code}"
174
+ result_area.value = body
175
+ ui.notify(
176
+ f"{resp.status_code}",
177
+ type="positive" if resp.status_code < 400 else "warning",
178
+ )
179
+ except Exception as e:
180
+ result_area.value = str(e)
181
+ ui.notify(str(e), type="negative")
182
+ finally:
183
+ run_btn.props(remove="loading")
184
+
185
+ run_btn.on("click", _run_query)
186
+
187
+ # --- Operation analysis ---
188
+ with ui.expansion("Operation Analysis", icon="analytics").classes("w-full"):
189
+ ui.label("Analyse a captured GraphQL request.").classes(
190
+ "text-caption text-grey q-mb-sm"
191
+ )
192
+
193
+ analyse_btn = ui.button("Analyse selected entry", icon="analytics").props(
194
+ "color=secondary size=sm"
195
+ )
196
+ analysis_area = (
197
+ ui.textarea()
198
+ .props("outlined dense rows=12 readonly")
199
+ .classes("w-full font-mono text-xs q-mt-xs")
200
+ )
201
+
202
+ def _analyse() -> None:
203
+ entry = state.get("entry")
204
+ if not entry or "graphql" not in entry.tags:
205
+ ui.notify("Select a GraphQL entry from Traffic", type="warning")
206
+ return
207
+
208
+ from pypproxy.graphql.detector import (
209
+ extract_field_names,
210
+ extract_operation_name,
211
+ extract_operation_type,
212
+ parse_operation,
213
+ )
214
+
215
+ op = parse_operation(entry.req_body)
216
+ query = op.get("query", "")
217
+ lines = [
218
+ f"Operation type : {extract_operation_type(query)}",
219
+ f"Operation name : {extract_operation_name(query) or '(anonymous)'}",
220
+ f"Fields detected: {', '.join(extract_field_names(query)) or '(none)'}",
221
+ "",
222
+ "--- Variables ---",
223
+ json.dumps(op.get("variables", {}), indent=2),
224
+ "",
225
+ "--- Query ---",
226
+ query,
227
+ ]
228
+ analysis_area.value = "\n".join(lines)
229
+
230
+ # Try to find schema for this host
231
+ schema = state.get("schema")
232
+ if schema:
233
+ root = schema.root_fields()
234
+ lines.append("")
235
+ lines.append(f"--- Schema root fields ({schema.query_type}) ---")
236
+ lines.extend(f" {f.name}: {f.type_name}" for f in root)
237
+ analysis_area.value = "\n".join(lines)
238
+
239
+ analyse_btn.on("click", _analyse)
240
+
241
+ def open_entry(entry: Entry) -> None:
242
+ state["entry"] = entry
243
+ entry_label.text = f"#{entry.id} {entry.method} {entry.scheme}://{entry.host}{entry.path}"
244
+ op_type_badge.text = entry.graphql_op_type or ""
245
+ op_name_label.text = entry.graphql_operation or ""
246
+ if entry.graphql_op_type == "mutation":
247
+ op_type_badge.props("color=warning")
248
+ elif entry.graphql_op_type == "subscription":
249
+ op_type_badge.props("color=info")
250
+ else:
251
+ op_type_badge.props("color=positive")
252
+
253
+ # Auto-fill query and variables from entry
254
+ from pypproxy.graphql.detector import parse_operation
255
+
256
+ op = parse_operation(entry.req_body)
257
+ query_input.value = op.get("query", "")
258
+ vars_input.value = (
259
+ json.dumps(op.get("variables", {}), indent=2) if op.get("variables") else ""
260
+ )
261
+
262
+ # Auto-set introspection URL
263
+ url_input.value = f"{entry.scheme}://{entry.host}{entry.path}"
264
+
265
+ return {"open_entry": open_entry}
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from nicegui import ui
4
+
5
+ from pypproxy.store.store import Store
6
+
7
+
8
+ def build_import_tab(store: Store) -> None:
9
+ """Build the Import tab content."""
10
+ with ui.column().classes("w-full h-full overflow-auto q-pa-md"):
11
+ ui.label("Import Traffic").classes("text-subtitle2 q-mb-sm")
12
+
13
+ # --- File upload ---
14
+ ui.label("Upload HAR or paxy JSON file:").classes("text-caption text-grey q-mb-xs")
15
+
16
+ async def _handle_upload(e) -> None: # noqa: ANN001
17
+ import json
18
+
19
+ from pypproxy.exporter.importer import import_har, import_json
20
+
21
+ try:
22
+ data = e.content.read()
23
+ # Auto-detect format
24
+ parsed = json.loads(data)
25
+ if "log" in parsed and "entries" in parsed.get("log", {}):
26
+ count = import_har(data, store)
27
+ ui.notify(f"Imported {count} entries from HAR", type="positive")
28
+ else:
29
+ count = import_json(data, store)
30
+ ui.notify(f"Imported {count} entries from JSON", type="positive")
31
+ except Exception as err:
32
+ ui.notify(f"Import failed: {err}", type="negative")
33
+
34
+ ui.upload(
35
+ label="Drop HAR/JSON here",
36
+ on_upload=_handle_upload,
37
+ auto_upload=True,
38
+ ).props("accept=.har,.json flat").classes("w-full q-mb-md")
39
+
40
+ ui.separator()
41
+
42
+ # --- Paste JSON ---
43
+ ui.label("Or paste HAR/JSON directly:").classes("text-caption text-grey q-mb-xs")
44
+ paste_area = (
45
+ ui.textarea(placeholder='{"log": {"entries": [...]}} or [{"method": "GET", ...}]')
46
+ .props("outlined dense rows=10")
47
+ .classes("w-full font-mono text-xs")
48
+ )
49
+
50
+ async def _import_paste() -> None:
51
+ import json
52
+
53
+ from pypproxy.exporter.importer import import_har, import_json
54
+
55
+ text = paste_area.value.strip()
56
+ if not text:
57
+ ui.notify("Nothing to import", type="warning")
58
+ return
59
+ try:
60
+ parsed = json.loads(text)
61
+ if "log" in parsed and "entries" in parsed.get("log", {}):
62
+ count = import_har(text, store)
63
+ ui.notify(f"Imported {count} entries from HAR", type="positive")
64
+ else:
65
+ count = import_json(text, store)
66
+ ui.notify(f"Imported {count} entries from JSON", type="positive")
67
+ paste_area.value = ""
68
+ except Exception as err:
69
+ ui.notify(f"Import failed: {err}", type="negative")
70
+
71
+ ui.button("Import", icon="upload", on_click=_import_paste).props("color=primary q-mt-sm")
72
+
73
+ ui.separator().classes("q-my-md")
74
+
75
+ # --- Full-text search ---
76
+ ui.label("Full-text search").classes("text-subtitle2 q-mb-sm")
77
+ ui.label("Search across all captured request/response bodies, headers, and URLs.").classes(
78
+ "text-caption text-grey q-mb-xs"
79
+ )
80
+
81
+ with ui.row().classes("gap-2 items-center w-full"):
82
+ search_input = (
83
+ ui.input(placeholder="Search term…").props("dense outlined dark").classes("flex-1")
84
+ )
85
+ search_btn = ui.button("Search", icon="search").props("color=primary size=sm")
86
+
87
+ search_label = ui.label("").classes("text-caption text-grey q-mt-xs")
88
+ search_table = (
89
+ ui.table(
90
+ columns=[
91
+ {
92
+ "name": "id",
93
+ "label": "ID",
94
+ "field": "entry_id",
95
+ "align": "right",
96
+ "style": "width:60px",
97
+ },
98
+ {"name": "snippet", "label": "Match", "field": "snippet", "align": "left"},
99
+ {
100
+ "name": "rank",
101
+ "label": "Score",
102
+ "field": "rank",
103
+ "align": "right",
104
+ "style": "width:80px",
105
+ },
106
+ ],
107
+ rows=[],
108
+ row_key="entry_id",
109
+ )
110
+ .classes("w-full")
111
+ .props("dense flat dark")
112
+ )
113
+
114
+ async def _search() -> None:
115
+ q = search_input.value.strip()
116
+ if not q:
117
+ return
118
+ db = getattr(store, "_db", None)
119
+ if db is None:
120
+ # fallback to in-memory
121
+ from pypproxy.store.models import Filter
122
+
123
+ entries, total = store.list(Filter(search=q), 0, 50)
124
+ rows = [
125
+ {"entry_id": e.id, "rank": 0.0, "snippet": f"{e.host}{e.path}"} for e in entries
126
+ ]
127
+ search_label.text = f"{total} results (in-memory search)"
128
+ else:
129
+ results = await db.search(q, limit=50)
130
+ rows = [r.to_dict() for r in results]
131
+ search_label.text = f"{len(rows)} results"
132
+ search_table.rows = rows
133
+ search_table.update()
134
+
135
+ search_btn.on("click", _search)
136
+ search_input.on("keydown.enter", _search)
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from nicegui import ui
6
+
7
+ from pypproxy.intercept.manager import InterceptManager, PendingRequest
8
+
9
+
10
+ def build_intercept_panel(mgr: InterceptManager, container: ui.element) -> None:
11
+ """Render the intercept panel inside container. Polls for pending requests."""
12
+
13
+ async def _poller() -> None:
14
+ q = mgr.subscribe()
15
+ try:
16
+ while True:
17
+ try:
18
+ req: PendingRequest = q.get_nowait()
19
+ _show_dialog(req, mgr)
20
+ except asyncio.QueueEmpty:
21
+ pass
22
+ await asyncio.sleep(0.1)
23
+ except asyncio.CancelledError:
24
+ mgr.unsubscribe(q)
25
+
26
+ asyncio.ensure_future(_poller())
27
+
28
+
29
+ def _show_dialog(req: PendingRequest, mgr: InterceptManager) -> None:
30
+ with ui.dialog() as dlg, ui.card().classes("w-full").style("min-width:700px"):
31
+ ui.label(f"Intercepted: {req.method} {req.scheme}://{req.host}{req.path}").classes(
32
+ "text-subtitle1 text-weight-bold"
33
+ )
34
+
35
+ # Headers editor
36
+ ui.label("Headers").classes("text-caption text-weight-bold q-mt-sm")
37
+ headers_text = "\n".join(f"{k}: {', '.join(v)}" for k, v in req.headers.items())
38
+ headers_input = (
39
+ ui.textarea(value=headers_text)
40
+ .props("outlined dense rows=8")
41
+ .classes("w-full font-mono text-xs")
42
+ )
43
+
44
+ # Body editor
45
+ ui.label("Body").classes("text-caption text-weight-bold q-mt-sm")
46
+ body_text = req.body.decode(errors="replace")
47
+ body_input = (
48
+ ui.textarea(value=body_text)
49
+ .props("outlined dense rows=6")
50
+ .classes("w-full font-mono text-xs")
51
+ )
52
+
53
+ def _forward() -> None:
54
+ # Parse edited headers back
55
+ edited_headers: dict[str, list[str]] = {}
56
+ for line in headers_input.value.splitlines():
57
+ if ":" in line:
58
+ k, _, v = line.partition(":")
59
+ edited_headers.setdefault(k.strip().lower(), []).append(v.strip())
60
+ edited_body = body_input.value.encode()
61
+ mgr.forward(req.id, edited_headers, edited_body)
62
+ dlg.close()
63
+ ui.notify("Forwarded", type="positive")
64
+
65
+ def _drop() -> None:
66
+ mgr.drop(req.id)
67
+ dlg.close()
68
+ ui.notify("Dropped", type="warning")
69
+
70
+ with ui.row().classes("q-mt-sm gap-2"):
71
+ ui.button("Forward", icon="send", on_click=_forward).props("color=positive size=sm")
72
+ ui.button("Drop", icon="block", on_click=_drop).props("color=negative size=sm")
73
+
74
+ dlg.open()
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+ from nicegui import ui
7
+
8
+ from pypproxy.store.models import Entry
9
+ from pypproxy.store.store import Store
10
+
11
+
12
+ def build_resender_tab(store: Store) -> None:
13
+ """Render the Resender tab content."""
14
+ state: dict = {"tabs": [], "active": None}
15
+
16
+ with ui.column().classes("w-full h-full"):
17
+ # Tab bar
18
+ with ui.row().classes("items-center gap-2 q-pa-sm").style("border-bottom:1px solid #333"):
19
+ ui.label("Resender").classes("text-subtitle2")
20
+ ui.button("+ New", icon="add", on_click=lambda: _new_tab(state, tab_panels)).props(
21
+ "size=sm flat color=primary"
22
+ )
23
+
24
+ # Tab panels
25
+ with ui.element("div").classes("w-full flex-1 overflow-auto") as tab_panels:
26
+ ui.label("Click '+ New' or drag an entry here to create a resender tab.").classes(
27
+ "text-grey q-pa-md"
28
+ )
29
+
30
+ # expose method to open entry
31
+ tab_panels._open_entry = lambda e: _open_entry(e, state, tab_panels) # type: ignore[attr-defined]
32
+
33
+
34
+ def open_entry_in_resender(entry: Entry, panel: ui.element) -> None:
35
+ fn = getattr(panel, "_open_entry", None)
36
+ if fn:
37
+ fn(entry)
38
+
39
+
40
+ def _new_tab(state: dict, container: ui.element, entry: Entry | None = None) -> None:
41
+ tab_id = len(state["tabs"])
42
+ tab_state: dict = {
43
+ "id": tab_id,
44
+ "method": entry.method if entry else "GET",
45
+ "url": f"{entry.scheme}://{entry.host}{entry.path}" if entry else "https://",
46
+ "headers": "\n".join(f"{k}: {', '.join(v)}" for k, v in (entry.req_headers or {}).items())
47
+ if entry
48
+ else "",
49
+ "body": entry.req_body.decode(errors="replace") if (entry and entry.req_body) else "",
50
+ "result": "",
51
+ }
52
+ state["tabs"].append(tab_state)
53
+ state["active"] = tab_id
54
+ container.clear()
55
+ _render_tabs(state, container)
56
+
57
+
58
+ def _open_entry(entry: Entry, state: dict, container: ui.element) -> None:
59
+ _new_tab(state, container, entry)
60
+
61
+
62
+ def _render_tabs(state: dict, container: ui.element) -> None:
63
+ container.clear()
64
+ with container:
65
+ for tab in state["tabs"]:
66
+ _render_tab(tab, state, container)
67
+
68
+
69
+ def _render_tab(tab: dict, state: dict, container: ui.element) -> None:
70
+ with ui.card().classes("w-full q-mb-sm"):
71
+ # Method + URL row
72
+ with ui.row().classes("items-center gap-2 w-full"):
73
+ method_input = (
74
+ ui.select(
75
+ ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
76
+ value=tab["method"],
77
+ )
78
+ .props("dense outlined dark")
79
+ .classes("w-28")
80
+ )
81
+ url_input = ui.input(value=tab["url"]).props("dense outlined dark").classes("flex-1")
82
+ send_btn = ui.button("Send", icon="send").props("color=primary size=sm")
83
+
84
+ # Headers
85
+ ui.label("Headers").classes("text-caption text-weight-bold q-mt-xs")
86
+ headers_input = (
87
+ ui.textarea(value=tab["headers"])
88
+ .props("outlined dense rows=4")
89
+ .classes("w-full font-mono text-xs")
90
+ )
91
+
92
+ # Body
93
+ ui.label("Body").classes("text-caption text-weight-bold")
94
+ body_input = (
95
+ ui.textarea(value=tab["body"])
96
+ .props("outlined dense rows=5")
97
+ .classes("w-full font-mono text-xs")
98
+ )
99
+
100
+ # Result
101
+ ui.label("Response").classes("text-caption text-weight-bold")
102
+ result_area = (
103
+ ui.textarea(value=tab["result"])
104
+ .props("outlined dense rows=8 readonly")
105
+ .classes("w-full font-mono text-xs")
106
+ )
107
+
108
+ async def _send() -> None:
109
+ method = method_input.value
110
+ url = url_input.value
111
+ headers: dict[str, str] = {}
112
+ for line in headers_input.value.splitlines():
113
+ if ":" in line:
114
+ k, _, v = line.partition(":")
115
+ headers[k.strip()] = v.strip()
116
+ body = body_input.value.encode()
117
+ try:
118
+ async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
119
+ resp = await client.request(
120
+ method=method, url=url, headers=headers, content=body
121
+ )
122
+ ct = resp.headers.get("content-type", "")
123
+ try:
124
+ if "json" in ct:
125
+ body_text = json.dumps(resp.json(), indent=2, ensure_ascii=False)
126
+ else:
127
+ body_text = resp.text
128
+ except Exception:
129
+ body_text = resp.text
130
+ result_area.value = (
131
+ f"HTTP {resp.status_code} ({resp.elapsed.total_seconds() * 1000:.0f}ms)\n"
132
+ + "\n".join(f"{k}: {v}" for k, v in resp.headers.items())
133
+ + f"\n\n{body_text}"
134
+ )
135
+ ui.notify(f"{resp.status_code}", type="positive")
136
+ except Exception as e:
137
+ result_area.value = f"Error: {e}"
138
+ ui.notify(str(e), type="negative")
139
+
140
+ send_btn.on("click", _send)