webtap-tool 0.7.0__py3-none-any.whl → 0.8.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.
Potentially problematic release.
This version of webtap-tool might be problematic. Click here for more details.
- webtap/__init__.py +4 -0
- webtap/api.py +50 -57
- webtap/app.py +5 -0
- webtap/cdp/session.py +166 -27
- webtap/commands/TIPS.md +125 -22
- webtap/commands/_builders.py +7 -1
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/body.py +9 -5
- webtap/commands/connection.py +21 -0
- webtap/commands/javascript.py +13 -25
- webtap/commands/navigation.py +5 -0
- webtap/commands/quicktype.py +268 -0
- webtap/commands/to_model.py +23 -75
- webtap/services/body.py +209 -24
- webtap/services/dom.py +19 -12
- webtap/services/fetch.py +19 -0
- webtap/services/main.py +194 -2
- webtap/services/state_snapshot.py +88 -0
- {webtap_tool-0.7.0.dist-info → webtap_tool-0.8.0.dist-info}/METADATA +1 -1
- {webtap_tool-0.7.0.dist-info → webtap_tool-0.8.0.dist-info}/RECORD +22 -19
- {webtap_tool-0.7.0.dist-info → webtap_tool-0.8.0.dist-info}/WHEEL +0 -0
- {webtap_tool-0.7.0.dist-info → webtap_tool-0.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Generate types and schemas from HTTP response bodies using quicktype."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from webtap.app import app
|
|
9
|
+
from webtap.commands._builders import check_connection, success_response, error_response
|
|
10
|
+
from webtap.commands._code_generation import ensure_output_directory
|
|
11
|
+
from webtap.commands._tips import get_mcp_description
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
mcp_desc = get_mcp_description("quicktype")
|
|
15
|
+
|
|
16
|
+
# Header template for generated files
|
|
17
|
+
HEADER_TEMPLATE = """Generated by WebTap from event {event_id}
|
|
18
|
+
Source: {url}
|
|
19
|
+
Method: {method}
|
|
20
|
+
Generated: {timestamp}
|
|
21
|
+
|
|
22
|
+
Do not edit manually."""
|
|
23
|
+
|
|
24
|
+
# Comment syntax per language
|
|
25
|
+
COMMENT_STYLES = {
|
|
26
|
+
"TypeScript": "//",
|
|
27
|
+
"Python": "#",
|
|
28
|
+
"Go": "//",
|
|
29
|
+
"Rust": "//",
|
|
30
|
+
"Java": "//",
|
|
31
|
+
"Kotlin": "//",
|
|
32
|
+
"Swift": "//",
|
|
33
|
+
"C#": "//",
|
|
34
|
+
"C++": "//",
|
|
35
|
+
"Dart": "//",
|
|
36
|
+
"Ruby": "#",
|
|
37
|
+
"JSON Schema": None, # No comments in JSON
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_quicktype(
|
|
42
|
+
json_data: dict | list,
|
|
43
|
+
output: str,
|
|
44
|
+
type_name: str = None, # pyright: ignore[reportArgumentType]
|
|
45
|
+
just_types: bool = True,
|
|
46
|
+
prefer_types: bool = True,
|
|
47
|
+
options: dict = None, # pyright: ignore[reportArgumentType]
|
|
48
|
+
) -> tuple[bool, str]:
|
|
49
|
+
"""Run quicktype CLI with given parameters.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
json_data: JSON data to convert
|
|
53
|
+
output: Output file path
|
|
54
|
+
type_name: Top-level type name
|
|
55
|
+
just_types: Generate only types, no serializers
|
|
56
|
+
prefer_types: Use 'type' instead of 'interface' for TypeScript
|
|
57
|
+
options: Additional quicktype flags
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (success: bool, error_message: str)
|
|
61
|
+
"""
|
|
62
|
+
# Check if quicktype is available
|
|
63
|
+
if not shutil.which("quicktype"):
|
|
64
|
+
return False, "quicktype CLI not found. Install with: npm install -g quicktype"
|
|
65
|
+
|
|
66
|
+
# Determine language from file extension
|
|
67
|
+
output_path = Path(output)
|
|
68
|
+
ext = output_path.suffix.lower()
|
|
69
|
+
|
|
70
|
+
# Build command
|
|
71
|
+
cmd = ["quicktype", "-o", str(output), "--src-lang", "json", "--top-level", type_name]
|
|
72
|
+
|
|
73
|
+
# Add opinionated defaults
|
|
74
|
+
if just_types:
|
|
75
|
+
cmd.append("--just-types")
|
|
76
|
+
|
|
77
|
+
# TypeScript-specific options
|
|
78
|
+
if ext in {".ts", ".tsx"} and prefer_types:
|
|
79
|
+
cmd.append("--prefer-types")
|
|
80
|
+
|
|
81
|
+
# Apply additional options
|
|
82
|
+
for key, val in (options or {}).items():
|
|
83
|
+
flag = f"--{key.replace('_', '-')}"
|
|
84
|
+
if val is True:
|
|
85
|
+
cmd.append(flag)
|
|
86
|
+
elif val is not False and val is not None:
|
|
87
|
+
cmd.extend([flag, str(val)])
|
|
88
|
+
|
|
89
|
+
# Run quicktype
|
|
90
|
+
try:
|
|
91
|
+
subprocess.run(
|
|
92
|
+
cmd,
|
|
93
|
+
input=json.dumps(json_data, indent=2),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True,
|
|
97
|
+
timeout=30,
|
|
98
|
+
)
|
|
99
|
+
return True, ""
|
|
100
|
+
except subprocess.CalledProcessError as e:
|
|
101
|
+
return False, f"quicktype failed: {e.stderr.strip() if e.stderr else str(e)}"
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
return False, "quicktype timed out (>30s)"
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return False, f"Unexpected error: {str(e)}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _insert_header(state, event: int, output_path: Path, language: str) -> None:
|
|
109
|
+
"""Insert language-aware header comment into generated file.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
state: WebTap state with CDP session
|
|
113
|
+
event: Event row ID for metadata extraction
|
|
114
|
+
output_path: Path to generated file
|
|
115
|
+
language: Target language (e.g., "TypeScript", "Python")
|
|
116
|
+
"""
|
|
117
|
+
if not HEADER_TEMPLATE or language not in COMMENT_STYLES:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
comment_prefix = COMMENT_STYLES[language]
|
|
121
|
+
if not comment_prefix: # Skip languages without comments (e.g., JSON)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Get event metadata
|
|
126
|
+
event_result = state.cdp.query("SELECT event FROM events WHERE rowid = ?", [event])
|
|
127
|
+
if not event_result:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
event_data = json.loads(event_result[0][0])
|
|
131
|
+
params = event_data.get("params", {})
|
|
132
|
+
|
|
133
|
+
# Extract metadata from event
|
|
134
|
+
request_data = params.get("request", {})
|
|
135
|
+
response_data = params.get("response", {})
|
|
136
|
+
|
|
137
|
+
metadata = {
|
|
138
|
+
"event_id": event,
|
|
139
|
+
"url": request_data.get("url") or response_data.get("url", "N/A"),
|
|
140
|
+
"method": request_data.get("method", "N/A"),
|
|
141
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Format header text with metadata
|
|
145
|
+
header_text = HEADER_TEMPLATE.format(**metadata)
|
|
146
|
+
|
|
147
|
+
# Apply comment syntax to each line
|
|
148
|
+
header_lines = [
|
|
149
|
+
f"{comment_prefix} {line}" if line.strip() else comment_prefix for line in header_text.split("\n")
|
|
150
|
+
]
|
|
151
|
+
header = "\n".join(header_lines)
|
|
152
|
+
|
|
153
|
+
# Prepend header to file
|
|
154
|
+
content = output_path.read_text()
|
|
155
|
+
output_path.write_text(header + "\n\n" + content)
|
|
156
|
+
except Exception:
|
|
157
|
+
# Silent failure - don't break generation if header fails
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
|
|
162
|
+
def quicktype(
|
|
163
|
+
state,
|
|
164
|
+
event: int,
|
|
165
|
+
output: str,
|
|
166
|
+
type_name: str,
|
|
167
|
+
json_path: str = None, # pyright: ignore[reportArgumentType]
|
|
168
|
+
expr: str = None, # pyright: ignore[reportArgumentType]
|
|
169
|
+
just_types: bool = True,
|
|
170
|
+
prefer_types: bool = True,
|
|
171
|
+
options: dict = None, # pyright: ignore[reportArgumentType]
|
|
172
|
+
) -> dict: # pyright: ignore[reportArgumentType]
|
|
173
|
+
"""Generate types/schemas from request or response body using quicktype CLI.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
event: Event row ID from network() or events()
|
|
177
|
+
output: Output file path (extension determines language: .ts, .py, .go, etc.)
|
|
178
|
+
type_name: Top-level type name (e.g., "User", "ApiResponse")
|
|
179
|
+
json_path: Optional JSON path to extract nested data (e.g., "data[0]")
|
|
180
|
+
expr: Optional Python expression to transform data (has 'body' and 'event' variables)
|
|
181
|
+
just_types: Generate only types, no serializers (default: True)
|
|
182
|
+
prefer_types: Use 'type' instead of 'interface' for TypeScript (default: True)
|
|
183
|
+
options: Additional quicktype flags as dict (e.g., {"readonly": True, "nice_property_names": True})
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
quicktype(123, "types/User.ts", "User") # TypeScript interface
|
|
187
|
+
quicktype(123, "models/customer.py", "Customer") # Python dataclass
|
|
188
|
+
quicktype(123, "api.go", "ApiResponse") # Go struct
|
|
189
|
+
quicktype(123, "schema.json", "Schema") # JSON Schema
|
|
190
|
+
quicktype(123, "types.ts", "User", json_path="data[0]") # Extract nested
|
|
191
|
+
quicktype(172, "types/Form.ts", "Form", expr="dict(urllib.parse.parse_qsl(body))") # Parse form data
|
|
192
|
+
quicktype(123, "types.ts", "User", options={"readonly": True}) # Advanced options
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Success message with generation details
|
|
196
|
+
"""
|
|
197
|
+
if error := check_connection(state):
|
|
198
|
+
return error
|
|
199
|
+
|
|
200
|
+
# Prepare data via service layer
|
|
201
|
+
result = state.service.body.prepare_for_generation(event, json_path, expr)
|
|
202
|
+
if result.get("error"):
|
|
203
|
+
return error_response(result["error"], suggestions=result.get("suggestions", []))
|
|
204
|
+
|
|
205
|
+
data = result["data"]
|
|
206
|
+
|
|
207
|
+
# Ensure output directory exists
|
|
208
|
+
output_path = ensure_output_directory(output)
|
|
209
|
+
|
|
210
|
+
# Run quicktype
|
|
211
|
+
success, error_msg = _run_quicktype(
|
|
212
|
+
json_data=data,
|
|
213
|
+
output=str(output_path),
|
|
214
|
+
type_name=type_name,
|
|
215
|
+
just_types=just_types,
|
|
216
|
+
prefer_types=prefer_types,
|
|
217
|
+
options=options,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if not success:
|
|
221
|
+
return error_response(
|
|
222
|
+
error_msg,
|
|
223
|
+
suggestions=[
|
|
224
|
+
"Check that quicktype is installed: npm install -g quicktype",
|
|
225
|
+
"Verify the JSON structure is valid",
|
|
226
|
+
"Try simplifying the data with json_path",
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Detect language from extension
|
|
231
|
+
ext = output_path.suffix.lower()
|
|
232
|
+
lang_map = {
|
|
233
|
+
".ts": "TypeScript",
|
|
234
|
+
".tsx": "TypeScript",
|
|
235
|
+
".py": "Python",
|
|
236
|
+
".go": "Go",
|
|
237
|
+
".rs": "Rust",
|
|
238
|
+
".java": "Java",
|
|
239
|
+
".kt": "Kotlin",
|
|
240
|
+
".swift": "Swift",
|
|
241
|
+
".cs": "C#",
|
|
242
|
+
".cpp": "C++",
|
|
243
|
+
".dart": "Dart",
|
|
244
|
+
".rb": "Ruby",
|
|
245
|
+
".json": "JSON Schema",
|
|
246
|
+
}
|
|
247
|
+
language = lang_map.get(ext, "Unknown")
|
|
248
|
+
|
|
249
|
+
# Add header comment with event metadata
|
|
250
|
+
_insert_header(state, event, output_path, language)
|
|
251
|
+
|
|
252
|
+
# Count lines in generated file
|
|
253
|
+
try:
|
|
254
|
+
file_content = output_path.read_text()
|
|
255
|
+
line_count = len(file_content.splitlines())
|
|
256
|
+
except Exception:
|
|
257
|
+
line_count = "unknown"
|
|
258
|
+
|
|
259
|
+
return success_response(
|
|
260
|
+
"Types generated successfully",
|
|
261
|
+
details={
|
|
262
|
+
"Output": str(output_path),
|
|
263
|
+
"Language": language,
|
|
264
|
+
"Type Name": type_name,
|
|
265
|
+
"Lines": line_count,
|
|
266
|
+
"Size": f"{output_path.stat().st_size} bytes",
|
|
267
|
+
},
|
|
268
|
+
)
|
webtap/commands/to_model.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Generate Pydantic models from HTTP response bodies."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from pathlib import Path
|
|
5
4
|
from datamodel_code_generator import generate, InputFileType, DataModelType
|
|
6
5
|
from webtap.app import app
|
|
7
6
|
from webtap.commands._builders import check_connection, success_response, error_response
|
|
7
|
+
from webtap.commands._code_generation import ensure_output_directory
|
|
8
8
|
from webtap.commands._tips import get_mcp_description
|
|
9
9
|
|
|
10
10
|
|
|
@@ -12,14 +12,20 @@ mcp_desc = get_mcp_description("to_model")
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@app.command(display="markdown", fastmcp={"type": "tool", "description": mcp_desc} if mcp_desc else {"type": "tool"})
|
|
15
|
-
def to_model(state,
|
|
16
|
-
"""Generate Pydantic model from response body using datamodel-codegen.
|
|
15
|
+
def to_model(state, event: int, output: str, model_name: str, json_path: str = None, expr: str = None) -> dict: # pyright: ignore[reportArgumentType]
|
|
16
|
+
"""Generate Pydantic model from request or response body using datamodel-codegen.
|
|
17
17
|
|
|
18
18
|
Args:
|
|
19
|
-
|
|
19
|
+
event: Event row ID from network() or events()
|
|
20
20
|
output: Output file path for generated model (e.g., "models/customers/group.py")
|
|
21
21
|
model_name: Class name for generated model (e.g., "CustomerGroup")
|
|
22
|
-
json_path: Optional JSON path to extract nested data (e.g., "
|
|
22
|
+
json_path: Optional JSON path to extract nested data (e.g., "data[0]")
|
|
23
|
+
expr: Optional Python expression to transform data (has 'body' and 'event' variables)
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
to_model(123, "models/user.py", "User", json_path="data[0]")
|
|
27
|
+
to_model(172, "models/form.py", "Form", expr="dict(urllib.parse.parse_qsl(body))")
|
|
28
|
+
to_model(123, "models/clean.py", "Clean", expr="{k: v for k, v in json.loads(body).items() if k != 'meta'}")
|
|
23
29
|
|
|
24
30
|
Returns:
|
|
25
31
|
Success message with generation details
|
|
@@ -27,73 +33,15 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
27
33
|
if error := check_connection(state):
|
|
28
34
|
return error
|
|
29
35
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
result
|
|
33
|
-
|
|
34
|
-
if "error" in result:
|
|
35
|
-
return error_response(result["error"])
|
|
36
|
-
|
|
37
|
-
body_content = result.get("body", "")
|
|
38
|
-
is_base64 = result.get("base64Encoded", False)
|
|
39
|
-
|
|
40
|
-
# Decode if needed
|
|
41
|
-
if is_base64:
|
|
42
|
-
decoded = body_service.decode_body(body_content, is_base64)
|
|
43
|
-
if isinstance(decoded, bytes):
|
|
44
|
-
return error_response(
|
|
45
|
-
"Response body is binary",
|
|
46
|
-
suggestions=["Only JSON responses can be converted to models", "Try a different response"],
|
|
47
|
-
)
|
|
48
|
-
body_content = decoded
|
|
36
|
+
# Prepare data via service layer
|
|
37
|
+
result = state.service.body.prepare_for_generation(event, json_path, expr)
|
|
38
|
+
if result.get("error"):
|
|
39
|
+
return error_response(result["error"], suggestions=result.get("suggestions", []))
|
|
49
40
|
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
data = json.loads(body_content)
|
|
53
|
-
except json.JSONDecodeError as e:
|
|
54
|
-
return error_response(
|
|
55
|
-
f"Invalid JSON: {e}",
|
|
56
|
-
suggestions=[
|
|
57
|
-
"Response must be valid JSON",
|
|
58
|
-
"Check the response with body() first",
|
|
59
|
-
"Try a different response",
|
|
60
|
-
],
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
# Extract JSON path if specified
|
|
64
|
-
if json_path:
|
|
65
|
-
try:
|
|
66
|
-
# Support simple bracket notation like "Data[0]"
|
|
67
|
-
parts = json_path.replace("[", ".").replace("]", "").split(".")
|
|
68
|
-
for part in parts:
|
|
69
|
-
if part:
|
|
70
|
-
if part.isdigit():
|
|
71
|
-
data = data[int(part)]
|
|
72
|
-
else:
|
|
73
|
-
data = data[part]
|
|
74
|
-
except (KeyError, IndexError, TypeError) as e:
|
|
75
|
-
return error_response(
|
|
76
|
-
f"JSON path extraction failed: {e}",
|
|
77
|
-
suggestions=[
|
|
78
|
-
f"Path '{json_path}' not found in response",
|
|
79
|
-
"Check the response structure with body()",
|
|
80
|
-
'Try a simpler path like "Data" or "Data[0]"',
|
|
81
|
-
],
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
# Ensure data is dict or list for model generation
|
|
85
|
-
if not isinstance(data, (dict, list)):
|
|
86
|
-
return error_response(
|
|
87
|
-
f"Extracted data is {type(data).__name__}, not dict or list",
|
|
88
|
-
suggestions=[
|
|
89
|
-
"Model generation requires dict or list structure",
|
|
90
|
-
"Adjust json_path to extract a complex object",
|
|
91
|
-
],
|
|
92
|
-
)
|
|
41
|
+
data = result["data"]
|
|
93
42
|
|
|
94
|
-
#
|
|
95
|
-
output_path =
|
|
96
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
# Ensure output directory exists
|
|
44
|
+
output_path = ensure_output_directory(output)
|
|
97
45
|
|
|
98
46
|
# Generate model using datamodel-codegen Python API
|
|
99
47
|
try:
|
|
@@ -103,10 +51,10 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
103
51
|
input_filename="response.json",
|
|
104
52
|
output=output_path,
|
|
105
53
|
output_model_type=DataModelType.PydanticV2BaseModel,
|
|
106
|
-
class_name=model_name,
|
|
107
|
-
snake_case_field=True,
|
|
108
|
-
use_standard_collections=True,
|
|
109
|
-
use_union_operator=True,
|
|
54
|
+
class_name=model_name,
|
|
55
|
+
snake_case_field=True,
|
|
56
|
+
use_standard_collections=True,
|
|
57
|
+
use_union_operator=True,
|
|
110
58
|
)
|
|
111
59
|
except Exception as e:
|
|
112
60
|
return error_response(
|
|
@@ -121,7 +69,7 @@ def to_model(state, response: int, output: str, model_name: str, json_path: str
|
|
|
121
69
|
# Count fields in generated model
|
|
122
70
|
try:
|
|
123
71
|
model_content = output_path.read_text()
|
|
124
|
-
field_count = model_content.count(": ")
|
|
72
|
+
field_count = model_content.count(": ")
|
|
125
73
|
except Exception:
|
|
126
74
|
field_count = "unknown"
|
|
127
75
|
|
webtap/services/body.py
CHANGED
|
@@ -19,17 +19,25 @@ class BodyService:
|
|
|
19
19
|
self.cdp: CDPSession | None = None
|
|
20
20
|
self._body_cache: dict[str, dict] = {}
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
"""Fetch response body for
|
|
22
|
+
def get_body(self, rowid: int, use_cache: bool = True) -> dict:
|
|
23
|
+
"""Fetch request or response body for an event.
|
|
24
|
+
|
|
25
|
+
Automatically detects event type and fetches appropriate body:
|
|
26
|
+
- Network.requestWillBeSent: request body (POST data)
|
|
27
|
+
- Network.responseReceived: response body
|
|
28
|
+
- Fetch.requestPaused: request or response body based on stage
|
|
24
29
|
|
|
25
30
|
Args:
|
|
26
|
-
rowid: Row ID from events table
|
|
31
|
+
rowid: Row ID from events table
|
|
27
32
|
use_cache: Whether to use cached body if available
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dict with 'body' (str), 'base64Encoded' (bool), and 'event' (dict), or 'error' (str)
|
|
28
36
|
"""
|
|
29
37
|
if not self.cdp:
|
|
30
38
|
return {"error": "No CDP session"}
|
|
31
39
|
|
|
32
|
-
# Get event from DB to extract requestId
|
|
40
|
+
# Get event from DB to extract requestId and method
|
|
33
41
|
result = self.cdp.query("SELECT event FROM events WHERE rowid = ?", [rowid])
|
|
34
42
|
|
|
35
43
|
if not result:
|
|
@@ -42,45 +50,89 @@ class BodyService:
|
|
|
42
50
|
|
|
43
51
|
method = event_data.get("method", "")
|
|
44
52
|
params = event_data.get("params", {})
|
|
53
|
+
request_id = params.get("requestId")
|
|
54
|
+
|
|
55
|
+
if not request_id:
|
|
56
|
+
return {"error": "No requestId in event"}
|
|
57
|
+
|
|
58
|
+
# Check cache first (cache includes event_data)
|
|
59
|
+
cache_key = f"{request_id}:{method}"
|
|
60
|
+
if use_cache and cache_key in self._body_cache:
|
|
61
|
+
logger.debug(f"Using cached body for {cache_key}")
|
|
62
|
+
return self._body_cache[cache_key]
|
|
63
|
+
|
|
64
|
+
# Handle request body (POST data)
|
|
65
|
+
if method == "Network.requestWillBeSent":
|
|
66
|
+
request = params.get("request", {})
|
|
67
|
+
|
|
68
|
+
# Check inline postData first (may be present for small bodies)
|
|
69
|
+
if request.get("postData"):
|
|
70
|
+
logger.debug(f"Using inline postData for {request_id}")
|
|
71
|
+
body_data = {"body": request["postData"], "base64Encoded": False, "event": event_data}
|
|
72
|
+
if use_cache:
|
|
73
|
+
self._body_cache[cache_key] = body_data
|
|
74
|
+
return body_data
|
|
75
|
+
|
|
76
|
+
# Check if request has POST data
|
|
77
|
+
if not request.get("hasPostData"):
|
|
78
|
+
return {"error": "No POST data in this request (GET or no body)"}
|
|
79
|
+
|
|
80
|
+
# Try to fetch POST data via CDP
|
|
81
|
+
try:
|
|
82
|
+
logger.debug(f"Fetching POST data for {request_id} using Network.getRequestPostData")
|
|
83
|
+
result = self.cdp.execute("Network.getRequestPostData", {"requestId": request_id})
|
|
84
|
+
body_data = {"body": result.get("postData", ""), "base64Encoded": False, "event": event_data}
|
|
85
|
+
|
|
86
|
+
if use_cache:
|
|
87
|
+
self._body_cache[cache_key] = body_data
|
|
88
|
+
logger.debug(f"Cached POST data for {request_id}")
|
|
89
|
+
|
|
90
|
+
return body_data
|
|
45
91
|
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
except Exception as e:
|
|
93
|
+
error_msg = str(e)
|
|
94
|
+
logger.error(f"Failed to fetch POST data for {request_id}: {error_msg}")
|
|
95
|
+
# Provide helpful error message
|
|
96
|
+
if "No resource with given identifier found" in error_msg:
|
|
97
|
+
return {"error": "POST data not available (possibly too large or not captured by CDP)"}
|
|
98
|
+
return {"error": f"Failed to fetch POST data: {error_msg}"}
|
|
99
|
+
|
|
100
|
+
# Handle response body
|
|
101
|
+
elif method == "Fetch.requestPaused":
|
|
48
102
|
# Fetch interception - verify it's response stage
|
|
49
103
|
if "responseStatusCode" not in params:
|
|
50
104
|
return {"error": "Not a response stage event (no responseStatusCode)"}
|
|
51
|
-
request_id = params.get("requestId")
|
|
52
105
|
domain = "Fetch"
|
|
106
|
+
|
|
53
107
|
elif method == "Network.responseReceived":
|
|
54
108
|
# Regular network response
|
|
55
|
-
request_id = params.get("requestId")
|
|
56
109
|
domain = "Network"
|
|
57
|
-
else:
|
|
58
|
-
return {"error": f"Not a response event (method: {method})"}
|
|
59
110
|
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if use_cache and request_id in self._body_cache:
|
|
65
|
-
logger.debug(f"Using cached body for {request_id}")
|
|
66
|
-
return self._body_cache[request_id]
|
|
111
|
+
else:
|
|
112
|
+
# Unknown event type - return empty body but include event for expr access
|
|
113
|
+
logger.debug(f"Unknown event type {method} - returning empty body with event data")
|
|
114
|
+
return {"body": "", "base64Encoded": False, "event": event_data}
|
|
67
115
|
|
|
116
|
+
# Fetch response body from CDP
|
|
68
117
|
try:
|
|
69
|
-
|
|
70
|
-
logger.debug(f"Fetching body for {request_id} using {domain}.getResponseBody")
|
|
118
|
+
logger.debug(f"Fetching response body for {request_id} using {domain}.getResponseBody")
|
|
71
119
|
result = self.cdp.execute(f"{domain}.getResponseBody", {"requestId": request_id})
|
|
72
120
|
|
|
73
|
-
body_data = {
|
|
121
|
+
body_data = {
|
|
122
|
+
"body": result.get("body", ""),
|
|
123
|
+
"base64Encoded": result.get("base64Encoded", False),
|
|
124
|
+
"event": event_data,
|
|
125
|
+
}
|
|
74
126
|
|
|
75
|
-
# Cache it
|
|
127
|
+
# Cache it
|
|
76
128
|
if use_cache:
|
|
77
|
-
self._body_cache[
|
|
78
|
-
logger.debug(f"Cached body for {request_id}")
|
|
129
|
+
self._body_cache[cache_key] = body_data
|
|
130
|
+
logger.debug(f"Cached response body for {request_id}")
|
|
79
131
|
|
|
80
132
|
return body_data
|
|
81
133
|
|
|
82
134
|
except Exception as e:
|
|
83
|
-
logger.error(f"Failed to fetch body for {request_id}: {e}")
|
|
135
|
+
logger.error(f"Failed to fetch response body for {request_id}: {e}")
|
|
84
136
|
return {"error": str(e)}
|
|
85
137
|
|
|
86
138
|
def clear_cache(self):
|
|
@@ -111,3 +163,136 @@ class BodyService:
|
|
|
111
163
|
except Exception as e:
|
|
112
164
|
logger.error(f"Failed to decode base64 body: {e}")
|
|
113
165
|
return body_content # Return original if decode fails
|
|
166
|
+
|
|
167
|
+
def prepare_for_generation(
|
|
168
|
+
self,
|
|
169
|
+
event: int,
|
|
170
|
+
json_path: str = None, # pyright: ignore[reportArgumentType]
|
|
171
|
+
expr: str = None, # pyright: ignore[reportArgumentType]
|
|
172
|
+
) -> dict:
|
|
173
|
+
"""Prepare HTTP body for code generation.
|
|
174
|
+
|
|
175
|
+
Orchestrates the complete pipeline:
|
|
176
|
+
1. Fetch body + event from CDP
|
|
177
|
+
2. Decode base64 if needed
|
|
178
|
+
3. Transform via expression OR validate and parse JSON
|
|
179
|
+
4. Extract nested data via json_path
|
|
180
|
+
5. Validate data structure (dict/list)
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
event: Event row ID from network() or events()
|
|
184
|
+
json_path: Optional JSON path for nested extraction (e.g., "data[0]")
|
|
185
|
+
expr: Optional Python expression with 'body' and 'event' variables
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dict with 'data' (dict|list) on success, or 'error' (str) on failure.
|
|
189
|
+
May include 'suggestions' (list[str]) for error guidance.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
result = body_service.prepare_for_generation(123, json_path="data[0]")
|
|
193
|
+
if result.get("error"):
|
|
194
|
+
return error_response(result["error"], suggestions=result.get("suggestions"))
|
|
195
|
+
data = result["data"]
|
|
196
|
+
"""
|
|
197
|
+
# 1. Fetch body + event from CDP
|
|
198
|
+
result = self.get_body(event, use_cache=True)
|
|
199
|
+
if "error" in result:
|
|
200
|
+
return {"error": result["error"], "suggestions": [], "data": None}
|
|
201
|
+
|
|
202
|
+
body_content = result["body"]
|
|
203
|
+
is_base64 = result["base64Encoded"]
|
|
204
|
+
event_data = result["event"]
|
|
205
|
+
|
|
206
|
+
# 2. Decode if base64
|
|
207
|
+
if is_base64:
|
|
208
|
+
decoded = self.decode_body(body_content, is_base64)
|
|
209
|
+
if isinstance(decoded, bytes):
|
|
210
|
+
return {
|
|
211
|
+
"error": "Body is binary content",
|
|
212
|
+
"suggestions": [
|
|
213
|
+
"Only text/JSON can be converted to code",
|
|
214
|
+
"Try a different event with text content",
|
|
215
|
+
],
|
|
216
|
+
"data": None,
|
|
217
|
+
}
|
|
218
|
+
body_content = decoded
|
|
219
|
+
|
|
220
|
+
# 3. Transform via expression OR validate and parse JSON
|
|
221
|
+
if expr:
|
|
222
|
+
# Use expression evaluation from _utils
|
|
223
|
+
from webtap.commands._utils import evaluate_expression
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
namespace = {"body": body_content, "event": event_data}
|
|
227
|
+
data, _ = evaluate_expression(expr, namespace)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return {
|
|
230
|
+
"error": f"Expression evaluation failed: {e}",
|
|
231
|
+
"suggestions": [
|
|
232
|
+
"Check your expression syntax",
|
|
233
|
+
"Variables available: 'body' (str), 'event' (dict)",
|
|
234
|
+
"Example: dict(urllib.parse.parse_qsl(body))",
|
|
235
|
+
"Example: json.loads(body)['data'][0]",
|
|
236
|
+
],
|
|
237
|
+
"data": None,
|
|
238
|
+
}
|
|
239
|
+
else:
|
|
240
|
+
# Validate body is not empty before parsing
|
|
241
|
+
if not body_content.strip():
|
|
242
|
+
return {
|
|
243
|
+
"error": "Body is empty",
|
|
244
|
+
"suggestions": [
|
|
245
|
+
"Use expr to extract data from event for non-HTTP events",
|
|
246
|
+
"Example: expr=\"json.loads(event['params']['response']['payloadData'])\"",
|
|
247
|
+
"Check the event structure with inspect() first",
|
|
248
|
+
],
|
|
249
|
+
"data": None,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Parse as JSON
|
|
253
|
+
from webtap.commands._code_generation import parse_json
|
|
254
|
+
|
|
255
|
+
data, error = parse_json(body_content)
|
|
256
|
+
if error:
|
|
257
|
+
return {
|
|
258
|
+
"error": error,
|
|
259
|
+
"suggestions": [
|
|
260
|
+
"Body must be valid JSON or use expr to transform it",
|
|
261
|
+
'For form data: expr="dict(urllib.parse.parse_qsl(body))"',
|
|
262
|
+
"Check the body with body() command first",
|
|
263
|
+
],
|
|
264
|
+
"data": None,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# 4. Extract nested path if specified
|
|
268
|
+
if json_path:
|
|
269
|
+
from webtap.commands._code_generation import extract_json_path
|
|
270
|
+
|
|
271
|
+
data, error = extract_json_path(data, json_path)
|
|
272
|
+
if error:
|
|
273
|
+
return {
|
|
274
|
+
"error": error,
|
|
275
|
+
"suggestions": [
|
|
276
|
+
f"Path '{json_path}' not found in body",
|
|
277
|
+
"Check the body structure with body() command",
|
|
278
|
+
'Try a simpler path like "data" or "data[0]"',
|
|
279
|
+
],
|
|
280
|
+
"data": None,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# 5. Validate structure
|
|
284
|
+
from webtap.commands._code_generation import validate_generation_data
|
|
285
|
+
|
|
286
|
+
is_valid, error = validate_generation_data(data)
|
|
287
|
+
if not is_valid:
|
|
288
|
+
return {
|
|
289
|
+
"error": error,
|
|
290
|
+
"suggestions": [
|
|
291
|
+
"Code generation requires dict or list structure",
|
|
292
|
+
"Adjust json_path to extract a complex object",
|
|
293
|
+
"Or use expr to transform data into dict/list",
|
|
294
|
+
],
|
|
295
|
+
"data": None,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {"data": data, "error": None, "suggestions": []}
|