webtap-tool 0.11.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.
- webtap/VISION.md +246 -0
- webtap/__init__.py +84 -0
- webtap/__main__.py +6 -0
- webtap/api/__init__.py +9 -0
- webtap/api/app.py +26 -0
- webtap/api/models.py +69 -0
- webtap/api/server.py +111 -0
- webtap/api/sse.py +182 -0
- webtap/api/state.py +89 -0
- webtap/app.py +79 -0
- webtap/cdp/README.md +275 -0
- webtap/cdp/__init__.py +12 -0
- webtap/cdp/har.py +302 -0
- webtap/cdp/schema/README.md +41 -0
- webtap/cdp/schema/cdp_protocol.json +32785 -0
- webtap/cdp/schema/cdp_version.json +8 -0
- webtap/cdp/session.py +667 -0
- webtap/client.py +81 -0
- webtap/commands/DEVELOPER_GUIDE.md +401 -0
- webtap/commands/TIPS.md +269 -0
- webtap/commands/__init__.py +29 -0
- webtap/commands/_builders.py +331 -0
- webtap/commands/_code_generation.py +110 -0
- webtap/commands/_tips.py +147 -0
- webtap/commands/_utils.py +273 -0
- webtap/commands/connection.py +220 -0
- webtap/commands/console.py +87 -0
- webtap/commands/fetch.py +310 -0
- webtap/commands/filters.py +116 -0
- webtap/commands/javascript.py +73 -0
- webtap/commands/js_export.py +73 -0
- webtap/commands/launch.py +72 -0
- webtap/commands/navigation.py +197 -0
- webtap/commands/network.py +136 -0
- webtap/commands/quicktype.py +306 -0
- webtap/commands/request.py +93 -0
- webtap/commands/selections.py +138 -0
- webtap/commands/setup.py +219 -0
- webtap/commands/to_model.py +163 -0
- webtap/daemon.py +185 -0
- webtap/daemon_state.py +53 -0
- webtap/filters.py +219 -0
- webtap/rpc/__init__.py +14 -0
- webtap/rpc/errors.py +49 -0
- webtap/rpc/framework.py +223 -0
- webtap/rpc/handlers.py +625 -0
- webtap/rpc/machine.py +84 -0
- webtap/services/README.md +83 -0
- webtap/services/__init__.py +15 -0
- webtap/services/console.py +124 -0
- webtap/services/dom.py +547 -0
- webtap/services/fetch.py +415 -0
- webtap/services/main.py +392 -0
- webtap/services/network.py +401 -0
- webtap/services/setup/__init__.py +185 -0
- webtap/services/setup/chrome.py +233 -0
- webtap/services/setup/desktop.py +255 -0
- webtap/services/setup/extension.py +147 -0
- webtap/services/setup/platform.py +162 -0
- webtap/services/state_snapshot.py +86 -0
- webtap_tool-0.11.0.dist-info/METADATA +535 -0
- webtap_tool-0.11.0.dist-info/RECORD +64 -0
- webtap_tool-0.11.0.dist-info/WHEEL +4 -0
- webtap_tool-0.11.0.dist-info/entry_points.txt +2 -0
webtap/filters.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Network request filter management for WebTap."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FilterGroup:
|
|
13
|
+
"""A named filter group with hide configuration."""
|
|
14
|
+
|
|
15
|
+
hide: dict # {"types": [...], "urls": [...]}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FilterManager:
|
|
19
|
+
"""Manages filter groups with file persistence and memory toggle state.
|
|
20
|
+
|
|
21
|
+
Groups define what to hide (types, URL patterns). Enabled state is in-memory
|
|
22
|
+
only - all groups start disabled on load. Group definitions persist to file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, filter_path: Path | str | None = None):
|
|
26
|
+
"""Initialize filter manager.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
filter_path: Path to filters.json. Defaults to .webtap/filters.json.
|
|
30
|
+
"""
|
|
31
|
+
if filter_path is None:
|
|
32
|
+
self.filter_path = Path.cwd() / ".webtap" / "filters.json"
|
|
33
|
+
else:
|
|
34
|
+
self.filter_path = Path(filter_path)
|
|
35
|
+
self.groups: dict[str, FilterGroup] = {}
|
|
36
|
+
self.enabled: set[str] = set()
|
|
37
|
+
|
|
38
|
+
def load(self) -> bool:
|
|
39
|
+
"""Load group definitions from file. All disabled by default.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if loaded successfully, False otherwise.
|
|
43
|
+
"""
|
|
44
|
+
if self.filter_path.exists():
|
|
45
|
+
try:
|
|
46
|
+
with open(self.filter_path) as f:
|
|
47
|
+
data = json.load(f)
|
|
48
|
+
self.groups = {
|
|
49
|
+
name: FilterGroup(hide=cfg.get("hide", {"types": [], "urls": []}))
|
|
50
|
+
for name, cfg in data.get("groups", {}).items()
|
|
51
|
+
}
|
|
52
|
+
self.enabled = set() # All disabled on load
|
|
53
|
+
logger.info(f"Loaded {len(self.groups)} filter groups from {self.filter_path}")
|
|
54
|
+
return True
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to load filters: {e}")
|
|
57
|
+
self.groups = {}
|
|
58
|
+
return False
|
|
59
|
+
else:
|
|
60
|
+
logger.debug(f"No filters found at {self.filter_path}")
|
|
61
|
+
self.groups = {}
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def save(self) -> bool:
|
|
65
|
+
"""Save group definitions to file (not enabled state).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if saved successfully, False on error.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
self.filter_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
data = {"groups": {name: {"hide": group.hide} for name, group in self.groups.items()}}
|
|
73
|
+
with open(self.filter_path, "w") as f:
|
|
74
|
+
json.dump(data, f, indent=2)
|
|
75
|
+
logger.info(f"Saved filters to {self.filter_path}")
|
|
76
|
+
return True
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.error(f"Failed to save filters: {e}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def add(self, name: str, hide: dict) -> None:
|
|
82
|
+
"""Add a group definition and persist to file.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
name: Group name.
|
|
86
|
+
hide: Filter config {"types": [...], "urls": [...]}.
|
|
87
|
+
"""
|
|
88
|
+
# Ensure hide has required keys
|
|
89
|
+
normalized_hide = {
|
|
90
|
+
"types": hide.get("types", []),
|
|
91
|
+
"urls": hide.get("urls", []),
|
|
92
|
+
}
|
|
93
|
+
self.groups[name] = FilterGroup(hide=normalized_hide)
|
|
94
|
+
self.save()
|
|
95
|
+
|
|
96
|
+
def remove(self, name: str) -> bool:
|
|
97
|
+
"""Remove a group and persist to file.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
name: Group name to remove.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if removed, False if not found.
|
|
104
|
+
"""
|
|
105
|
+
if name in self.groups:
|
|
106
|
+
del self.groups[name]
|
|
107
|
+
self.enabled.discard(name)
|
|
108
|
+
self.save()
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def enable(self, name: str) -> bool:
|
|
113
|
+
"""Enable a group (in-memory only).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
name: Group name to enable.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
True if group exists and was enabled, False otherwise.
|
|
120
|
+
"""
|
|
121
|
+
if name in self.groups:
|
|
122
|
+
self.enabled.add(name)
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def disable(self, name: str) -> bool:
|
|
127
|
+
"""Disable a group (in-memory only).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
name: Group name to disable.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
True if group exists and was disabled, False otherwise.
|
|
134
|
+
"""
|
|
135
|
+
if name in self.groups:
|
|
136
|
+
self.enabled.discard(name)
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def get_active_filters(self) -> dict:
|
|
141
|
+
"""Get consolidated filters from enabled groups (deduplicated).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dict with "types" and "urls" lists from all enabled groups.
|
|
145
|
+
"""
|
|
146
|
+
hide_types: set[str] = set()
|
|
147
|
+
hide_urls: set[str] = set()
|
|
148
|
+
for name in self.enabled:
|
|
149
|
+
if name in self.groups:
|
|
150
|
+
hide_types.update(self.groups[name].hide.get("types", []))
|
|
151
|
+
hide_urls.update(self.groups[name].hide.get("urls", []))
|
|
152
|
+
return {"types": list(hide_types), "urls": list(hide_urls)}
|
|
153
|
+
|
|
154
|
+
def get_status(self) -> dict:
|
|
155
|
+
"""Get all groups with enabled status.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Dict mapping group names to their config and enabled status.
|
|
159
|
+
"""
|
|
160
|
+
return {
|
|
161
|
+
name: {
|
|
162
|
+
"enabled": name in self.enabled,
|
|
163
|
+
"hide": group.hide,
|
|
164
|
+
}
|
|
165
|
+
for name, group in self.groups.items()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def build_filter_sql(
|
|
169
|
+
self,
|
|
170
|
+
status: int | None = None,
|
|
171
|
+
method: str | None = None,
|
|
172
|
+
type_filter: str | None = None,
|
|
173
|
+
url: str | None = None,
|
|
174
|
+
apply_groups: bool = True,
|
|
175
|
+
) -> str:
|
|
176
|
+
"""Build SQL WHERE conditions for har_summary filtering.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
status: Filter by HTTP status code.
|
|
180
|
+
method: Filter by HTTP method.
|
|
181
|
+
type_filter: Filter by resource type.
|
|
182
|
+
url: Filter by URL pattern (supports * wildcard).
|
|
183
|
+
apply_groups: Apply enabled filter groups.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
SQL WHERE clause conditions (without WHERE keyword).
|
|
187
|
+
"""
|
|
188
|
+
conditions = []
|
|
189
|
+
|
|
190
|
+
# Inline filters
|
|
191
|
+
if status is not None:
|
|
192
|
+
conditions.append(f"status = {status}")
|
|
193
|
+
if method:
|
|
194
|
+
conditions.append(f"UPPER(method) = '{method.upper()}'")
|
|
195
|
+
if type_filter:
|
|
196
|
+
conditions.append(f"LOWER(type) = '{type_filter.lower()}'")
|
|
197
|
+
if url:
|
|
198
|
+
sql_pattern = url.replace("'", "''").replace("*", "%")
|
|
199
|
+
conditions.append(f"url LIKE '{sql_pattern}'")
|
|
200
|
+
|
|
201
|
+
# Apply enabled filter groups
|
|
202
|
+
if apply_groups:
|
|
203
|
+
active = self.get_active_filters()
|
|
204
|
+
|
|
205
|
+
# Hide matching types
|
|
206
|
+
if active["types"]:
|
|
207
|
+
escaped_types = [t.replace("'", "''") for t in active["types"]]
|
|
208
|
+
type_list = ", ".join(f"'{t}'" for t in escaped_types)
|
|
209
|
+
conditions.append(f"type NOT IN ({type_list})")
|
|
210
|
+
|
|
211
|
+
# Hide matching URLs
|
|
212
|
+
for pattern in active["urls"]:
|
|
213
|
+
sql_pattern = pattern.replace("'", "''").replace("*", "%")
|
|
214
|
+
conditions.append(f"url NOT LIKE '{sql_pattern}'")
|
|
215
|
+
|
|
216
|
+
return " AND ".join(conditions) if conditions else ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = ["FilterManager"]
|
webtap/rpc/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""WebTap RPC Framework.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- RPCFramework: Core RPC request/response handler
|
|
5
|
+
- RPCError: Exception class for structured errors
|
|
6
|
+
- ErrorCode: Standard RPC error codes
|
|
7
|
+
- ConnectionState: Connection state machine states
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from webtap.rpc.errors import ErrorCode, RPCError
|
|
11
|
+
from webtap.rpc.framework import RPCFramework
|
|
12
|
+
from webtap.rpc.machine import ConnectionState
|
|
13
|
+
|
|
14
|
+
__all__ = ["RPCFramework", "RPCError", "ErrorCode", "ConnectionState"]
|
webtap/rpc/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""RPC error definitions.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- ErrorCode: Standard JSON-RPC 2.0 error codes
|
|
5
|
+
- RPCError: Structured exception for RPC errors
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ErrorCode:
|
|
12
|
+
"""Standard JSON-RPC 2.0 error codes.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
METHOD_NOT_FOUND: RPC method not found
|
|
16
|
+
INVALID_STATE: Operation invalid in current state
|
|
17
|
+
STALE_EPOCH: Request epoch does not match current epoch
|
|
18
|
+
INVALID_PARAMS: Invalid or missing parameters
|
|
19
|
+
INTERNAL_ERROR: Internal server error
|
|
20
|
+
NOT_CONNECTED: Not connected to a Chrome page
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
METHOD_NOT_FOUND = "METHOD_NOT_FOUND"
|
|
24
|
+
INVALID_STATE = "INVALID_STATE"
|
|
25
|
+
STALE_EPOCH = "STALE_EPOCH"
|
|
26
|
+
INVALID_PARAMS = "INVALID_PARAMS"
|
|
27
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
28
|
+
NOT_CONNECTED = "NOT_CONNECTED"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RPCError(Exception):
|
|
32
|
+
"""Structured RPC error with code, message, and optional data.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
code: Error code from ErrorCode constants
|
|
36
|
+
message: Human-readable error message
|
|
37
|
+
data: Optional additional error details
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
code: Error code
|
|
41
|
+
message: Error message
|
|
42
|
+
data: Additional error details
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, message: str, data: dict[str, Any] | None = None):
|
|
46
|
+
self.code = code
|
|
47
|
+
self.message = message
|
|
48
|
+
self.data = data or {}
|
|
49
|
+
super().__init__(message)
|
webtap/rpc/framework.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""RPC framework for JSON-RPC 2.0 request handling.
|
|
2
|
+
|
|
3
|
+
PUBLIC API:
|
|
4
|
+
- RPCFramework: Core RPC request/response handler with method registration
|
|
5
|
+
- RPCContext: Context passed to RPC handlers
|
|
6
|
+
- HandlerMeta: Metadata for RPC handler registration
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from webtap.rpc.errors import ErrorCode, RPCError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from webtap.rpc.machine import ConnectionMachine
|
|
19
|
+
from webtap.services.main import WebTapService
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RPCContext:
|
|
26
|
+
"""Context passed to RPC handlers.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
service: WebTapService instance for accessing CDP and domain services
|
|
30
|
+
machine: ConnectionMachine for state management
|
|
31
|
+
epoch: Current connection epoch
|
|
32
|
+
request_id: JSON-RPC request ID
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
service: "WebTapService"
|
|
36
|
+
machine: "ConnectionMachine"
|
|
37
|
+
epoch: int
|
|
38
|
+
request_id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class HandlerMeta:
|
|
43
|
+
"""Metadata for RPC handler registration.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
requires_state: List of valid connection states for this handler
|
|
47
|
+
broadcasts: Whether to trigger SSE broadcast after successful execution. Defaults to True.
|
|
48
|
+
requires_paused_request: Whether to lookup and inject paused request into kwargs. Defaults to False.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
requires_state: list[str]
|
|
52
|
+
broadcasts: bool = True
|
|
53
|
+
requires_paused_request: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RPCFramework:
|
|
57
|
+
"""JSON-RPC 2.0 framework with state machine integration.
|
|
58
|
+
|
|
59
|
+
Handles JSON-RPC 2.0 request routing, validation, and response formatting.
|
|
60
|
+
Integrates with ConnectionMachine for state management.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, service: "WebTapService"):
|
|
64
|
+
self.service = service
|
|
65
|
+
from webtap.rpc.machine import ConnectionMachine
|
|
66
|
+
|
|
67
|
+
self.machine = ConnectionMachine()
|
|
68
|
+
self.handlers: dict[str, tuple[Callable, HandlerMeta]] = {}
|
|
69
|
+
|
|
70
|
+
def method(
|
|
71
|
+
self,
|
|
72
|
+
name: str,
|
|
73
|
+
requires_state: list[str] | None = None,
|
|
74
|
+
broadcasts: bool = True,
|
|
75
|
+
requires_paused_request: bool = False,
|
|
76
|
+
) -> Callable:
|
|
77
|
+
"""Decorator to register RPC method handlers.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: RPC method name (e.g., "connect", "browser.startInspect")
|
|
81
|
+
requires_state: List of valid states for this method. Defaults to None.
|
|
82
|
+
broadcasts: Whether to trigger SSE broadcast after successful execution. Defaults to True.
|
|
83
|
+
requires_paused_request: Auto-lookup paused request by 'id' param and inject as 'paused'. Defaults to False.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Decorator function for handler registration.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
@rpc.method("connect")
|
|
90
|
+
def connect(ctx: RPCContext, page_id: str = None) -> dict:
|
|
91
|
+
return {"connected": True}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def decorator(func: Callable) -> Callable:
|
|
95
|
+
meta = HandlerMeta(
|
|
96
|
+
requires_state=requires_state or [],
|
|
97
|
+
broadcasts=broadcasts,
|
|
98
|
+
requires_paused_request=requires_paused_request,
|
|
99
|
+
)
|
|
100
|
+
self.handlers[name] = (func, meta)
|
|
101
|
+
return func
|
|
102
|
+
|
|
103
|
+
return decorator
|
|
104
|
+
|
|
105
|
+
async def handle(self, request: dict) -> dict:
|
|
106
|
+
"""Handle JSON-RPC 2.0 request.
|
|
107
|
+
|
|
108
|
+
Validates request format, routes to handler, manages state transitions.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
request: JSON-RPC 2.0 request dict
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
JSON-RPC 2.0 response dict (success or error)
|
|
115
|
+
"""
|
|
116
|
+
request_id = request.get("id", "")
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
# Validate JSON-RPC 2.0 format
|
|
120
|
+
if request.get("jsonrpc") != "2.0":
|
|
121
|
+
return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Invalid JSON-RPC version")
|
|
122
|
+
|
|
123
|
+
method = request.get("method")
|
|
124
|
+
if not method:
|
|
125
|
+
return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Missing method")
|
|
126
|
+
|
|
127
|
+
params = request.get("params", {})
|
|
128
|
+
|
|
129
|
+
# Find handler
|
|
130
|
+
if method not in self.handlers:
|
|
131
|
+
return self._error_response(request_id, ErrorCode.METHOD_NOT_FOUND, f"Unknown method: {method}")
|
|
132
|
+
|
|
133
|
+
handler, meta = self.handlers[method]
|
|
134
|
+
|
|
135
|
+
# Validate state requirements
|
|
136
|
+
current_state = self.machine.state
|
|
137
|
+
if meta.requires_state and current_state not in meta.requires_state:
|
|
138
|
+
return self._error_response(
|
|
139
|
+
request_id,
|
|
140
|
+
ErrorCode.INVALID_STATE,
|
|
141
|
+
f"Method {method} requires state {meta.requires_state}, current: {current_state}",
|
|
142
|
+
{"current_state": current_state, "required_states": meta.requires_state},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Validate epoch (if provided)
|
|
146
|
+
request_epoch = request.get("epoch")
|
|
147
|
+
if request_epoch is not None and request_epoch != self.machine.epoch:
|
|
148
|
+
return self._error_response(
|
|
149
|
+
request_id,
|
|
150
|
+
ErrorCode.STALE_EPOCH,
|
|
151
|
+
f"Request epoch {request_epoch} does not match current {self.machine.epoch}",
|
|
152
|
+
{"request_epoch": request_epoch, "current_epoch": self.machine.epoch},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Create context
|
|
156
|
+
ctx = RPCContext(
|
|
157
|
+
service=self.service, machine=self.machine, epoch=self.machine.epoch, request_id=request_id
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Auto-lookup paused request if required
|
|
161
|
+
if meta.requires_paused_request:
|
|
162
|
+
request_id_param = params.get("id")
|
|
163
|
+
if request_id_param is None:
|
|
164
|
+
return self._error_response(request_id, ErrorCode.INVALID_PARAMS, "Missing 'id' parameter")
|
|
165
|
+
|
|
166
|
+
network_id = self.service.network.get_request_id(request_id_param)
|
|
167
|
+
if not network_id:
|
|
168
|
+
return self._error_response(
|
|
169
|
+
request_id, ErrorCode.INVALID_PARAMS, f"Request {request_id_param} not found"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
paused = self.service.fetch.get_paused_by_network_id(network_id)
|
|
173
|
+
if not paused:
|
|
174
|
+
return self._error_response(
|
|
175
|
+
request_id, ErrorCode.INVALID_PARAMS, f"Request {request_id_param} is not paused"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
params["paused"] = paused
|
|
179
|
+
|
|
180
|
+
# Execute handler in thread pool (service methods are sync)
|
|
181
|
+
try:
|
|
182
|
+
result = await asyncio.to_thread(handler, ctx, **params)
|
|
183
|
+
|
|
184
|
+
# Auto-broadcast for state-modifying handlers
|
|
185
|
+
if meta.broadcasts:
|
|
186
|
+
self.service._trigger_broadcast()
|
|
187
|
+
|
|
188
|
+
return self._success_response(request_id, result)
|
|
189
|
+
except RPCError as e:
|
|
190
|
+
return self._error_response(request_id, e.code, e.message, e.data)
|
|
191
|
+
except TypeError as e:
|
|
192
|
+
# Parameter mismatch (missing/extra params)
|
|
193
|
+
return self._error_response(request_id, ErrorCode.INVALID_PARAMS, f"Invalid parameters: {e}")
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.exception(f"RPC handler error: {method}")
|
|
196
|
+
return self._error_response(request_id, ErrorCode.INTERNAL_ERROR, str(e))
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.exception("RPC request processing error")
|
|
200
|
+
return self._error_response(request_id, ErrorCode.INTERNAL_ERROR, str(e))
|
|
201
|
+
|
|
202
|
+
def _success_response(self, request_id: str, result: Any) -> dict:
|
|
203
|
+
"""Build JSON-RPC 2.0 success response.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
request_id: JSON-RPC request ID
|
|
207
|
+
result: Result data to return
|
|
208
|
+
"""
|
|
209
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result, "epoch": self.machine.epoch}
|
|
210
|
+
|
|
211
|
+
def _error_response(self, request_id: str, code: str, message: str, data: dict | None = None) -> dict:
|
|
212
|
+
"""Build JSON-RPC 2.0 error response.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
request_id: JSON-RPC request ID
|
|
216
|
+
code: Error code
|
|
217
|
+
message: Error message
|
|
218
|
+
data: Optional error data
|
|
219
|
+
"""
|
|
220
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
221
|
+
if data:
|
|
222
|
+
error["data"] = data
|
|
223
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": error, "epoch": self.machine.epoch}
|