python-jsonrpc-lib 0.3.1__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.
- jsonrpc/__init__.py +83 -0
- jsonrpc/errors.py +131 -0
- jsonrpc/jsonrpc.py +881 -0
- jsonrpc/method.py +561 -0
- jsonrpc/openapi.py +505 -0
- jsonrpc/py.typed +0 -0
- jsonrpc/request.py +164 -0
- jsonrpc/response.py +169 -0
- jsonrpc/types.py +53 -0
- jsonrpc/validation.py +297 -0
- python_jsonrpc_lib-0.3.1.dist-info/METADATA +141 -0
- python_jsonrpc_lib-0.3.1.dist-info/RECORD +14 -0
- python_jsonrpc_lib-0.3.1.dist-info/WHEEL +4 -0
- python_jsonrpc_lib-0.3.1.dist-info/licenses/LICENSE +21 -0
jsonrpc/jsonrpc.py
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
"""Main JSONRPC class for handling JSON-RPC protocol."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import field, fields, make_dataclass
|
|
10
|
+
from typing import Any, cast, get_type_hints
|
|
11
|
+
|
|
12
|
+
from .errors import (
|
|
13
|
+
InternalError,
|
|
14
|
+
InvalidRequestError,
|
|
15
|
+
JSONRPCError,
|
|
16
|
+
MethodNotFoundError,
|
|
17
|
+
ParseError,
|
|
18
|
+
)
|
|
19
|
+
from .method import Method, MethodGroup
|
|
20
|
+
from .request import parse_request
|
|
21
|
+
from .response import _dataclass_to_dict, build_error_response, build_response
|
|
22
|
+
from .types import Request, Version
|
|
23
|
+
from .validation import is_batch
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger('jsonrpc-lib')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_decorator_function(func: Callable[..., Any], sig: inspect.Signature, hints: dict[str, Any]) -> None:
|
|
29
|
+
"""Validate that function is suitable for @rpc.method decoration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
func: Function to validate
|
|
33
|
+
sig: Function signature from inspect.signature()
|
|
34
|
+
hints: Type hints from get_type_hints()
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
TypeError: If function has missing type hints, unsupported parameters, or other issues
|
|
38
|
+
ValueError: If function signature is invalid
|
|
39
|
+
"""
|
|
40
|
+
func_name = func.__name__
|
|
41
|
+
|
|
42
|
+
for param in sig.parameters.values():
|
|
43
|
+
if param.annotation is inspect.Parameter.empty:
|
|
44
|
+
raise TypeError(
|
|
45
|
+
f"Function '{func_name}' parameter '{param.name}' is missing type hint. "
|
|
46
|
+
f'The @rpc.method decorator requires type hints for all parameters. '
|
|
47
|
+
f'Example: def {func_name}({param.name}: int, ...) -> ReturnType'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if 'return' not in hints or hints['return'] is type(None):
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"Function '{func_name}' is missing return type annotation. "
|
|
53
|
+
f'The @rpc.method decorator requires a return type. '
|
|
54
|
+
f"Add '-> ReturnType' to the function signature. "
|
|
55
|
+
f'Example: def {func_name}(...) -> int:'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Check for 'context' parameter (not supported in simplified decorator API)
|
|
59
|
+
if 'context' in sig.parameters:
|
|
60
|
+
raise TypeError(
|
|
61
|
+
f"Function '{func_name}' cannot have 'context' parameter. "
|
|
62
|
+
f'The @rpc.method decorator does not support context for simplicity. '
|
|
63
|
+
f'This is a prototyping-only feature. '
|
|
64
|
+
f'For production use with context support, create a Method subclass instead.'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _create_params_dataclass(func_name: str, sig: inspect.Signature, hints: dict[str, Any]) -> type | None:
|
|
69
|
+
"""Create dataclass for function parameters.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
func_name: Function name (used for dataclass name)
|
|
73
|
+
sig: Function signature from inspect.signature()
|
|
74
|
+
hints: Type hints from get_type_hints()
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dataclass type for parameters, or None if function has no parameters
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
For: def add(a: int, b: int) -> int
|
|
81
|
+
Returns: dataclass with fields a: int, b: int
|
|
82
|
+
"""
|
|
83
|
+
field_defs: list[Any] = []
|
|
84
|
+
|
|
85
|
+
for param in sig.parameters.values():
|
|
86
|
+
if param.name == 'self':
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
param_type = hints[param.name]
|
|
90
|
+
|
|
91
|
+
if param.default is inspect.Parameter.empty:
|
|
92
|
+
field_defs.append((param.name, param_type))
|
|
93
|
+
else:
|
|
94
|
+
field_defs.append((param.name, param_type, field(default=param.default)))
|
|
95
|
+
|
|
96
|
+
if not field_defs:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
# Create dataclass with capitalized function name + 'Params' suffix
|
|
100
|
+
# Example: 'add' -> 'AddParams', 'fetch_data' -> 'FetchDataParams'
|
|
101
|
+
dataclass_name = f'{func_name.title().replace("_", "")}Params'
|
|
102
|
+
|
|
103
|
+
return make_dataclass(dataclass_name, field_defs, frozen=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _create_method_class(func: Callable[..., Any], params_type: type | None, return_type: type, is_async: bool) -> type:
|
|
107
|
+
"""Create Method subclass for decorated function.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
func: Original function to wrap
|
|
111
|
+
params_type: Dataclass type for parameters (or None if no params)
|
|
112
|
+
return_type: Return type annotation
|
|
113
|
+
is_async: Whether function is async
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Method subclass with execute() method that calls original function
|
|
117
|
+
|
|
118
|
+
The execute() method must have explicit type annotations so Method.__init_subclass__()
|
|
119
|
+
can extract params_type and result_type for OpenAPI generation.
|
|
120
|
+
|
|
121
|
+
The class docstring is copied from the original function for OpenAPI documentation.
|
|
122
|
+
"""
|
|
123
|
+
if is_async:
|
|
124
|
+
|
|
125
|
+
class DecoratedAsyncMethod(Method):
|
|
126
|
+
async def execute(self, params: params_type) -> return_type: # type: ignore[valid-type, override]
|
|
127
|
+
if params is None:
|
|
128
|
+
return await func() # type: ignore[no-any-return]
|
|
129
|
+
kwargs = {f.name: getattr(params, f.name) for f in fields(params)}
|
|
130
|
+
return await func(**kwargs)
|
|
131
|
+
|
|
132
|
+
if func.__doc__:
|
|
133
|
+
DecoratedAsyncMethod.__doc__ = func.__doc__
|
|
134
|
+
|
|
135
|
+
return DecoratedAsyncMethod
|
|
136
|
+
else:
|
|
137
|
+
|
|
138
|
+
class DecoratedMethod(Method):
|
|
139
|
+
def execute(self, params: params_type) -> return_type: # type: ignore[valid-type, override]
|
|
140
|
+
if params is None:
|
|
141
|
+
return func() # type: ignore[no-any-return]
|
|
142
|
+
kwargs = {f.name: getattr(params, f.name) for f in fields(params)}
|
|
143
|
+
return func(**kwargs)
|
|
144
|
+
|
|
145
|
+
if func.__doc__:
|
|
146
|
+
DecoratedMethod.__doc__ = func.__doc__
|
|
147
|
+
|
|
148
|
+
return DecoratedMethod
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class JSONRPC:
|
|
152
|
+
"""Main JSON-RPC protocol handler.
|
|
153
|
+
|
|
154
|
+
Handles request parsing, method dispatch, and response building.
|
|
155
|
+
Supports both JSON-RPC 1.0 and 2.0 specifications.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> rpc = JSONRPC(version="2.0")
|
|
159
|
+
>>> math = MethodGroup()
|
|
160
|
+
>>> math.register("add", AddMethod())
|
|
161
|
+
>>> rpc.register("math", math)
|
|
162
|
+
>>> response = rpc.handle('{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":2},"id":1}')
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
version: Version = '2.0',
|
|
168
|
+
validate_results: bool = False,
|
|
169
|
+
context_type: type | None = None,
|
|
170
|
+
allow_batch: bool | None = None,
|
|
171
|
+
allow_dict_params: bool | None = None,
|
|
172
|
+
allow_list_params: bool | None = None,
|
|
173
|
+
max_batch: int = 100,
|
|
174
|
+
max_concurrent: int | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Initialize JSON-RPC handler.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
version: Default protocol version for responses
|
|
180
|
+
validate_results: If True, validate method results against result_type
|
|
181
|
+
context_type: Expected context type for validation (None = no validation)
|
|
182
|
+
allow_batch: Allow batch requests (None = spec-compliant default)
|
|
183
|
+
allow_dict_params: Allow dict/object params (None = spec-compliant default)
|
|
184
|
+
allow_list_params: Allow array params (None = spec-compliant default)
|
|
185
|
+
max_batch: Maximum number of requests in a batch (-1 for unlimited, default: 100)
|
|
186
|
+
max_concurrent: Maximum concurrent coroutines in async batch (None = os.cpu_count(),
|
|
187
|
+
-1 for unlimited, default: None). Sync batch is unaffected.
|
|
188
|
+
|
|
189
|
+
Defaults (spec-compliant):
|
|
190
|
+
v1.0: allow_batch=False, allow_dict_params=False, allow_list_params=True
|
|
191
|
+
v2.0: allow_batch=True, allow_dict_params=True, allow_list_params=True
|
|
192
|
+
"""
|
|
193
|
+
self.version = version
|
|
194
|
+
self.validate_results = validate_results
|
|
195
|
+
self.context_type = context_type
|
|
196
|
+
self.max_batch = max_batch
|
|
197
|
+
self.max_concurrent = max_concurrent
|
|
198
|
+
|
|
199
|
+
if allow_batch is None:
|
|
200
|
+
self.allow_batch = version == '2.0'
|
|
201
|
+
else:
|
|
202
|
+
self.allow_batch = allow_batch
|
|
203
|
+
|
|
204
|
+
if allow_dict_params is None:
|
|
205
|
+
self.allow_dict_params = version == '2.0'
|
|
206
|
+
else:
|
|
207
|
+
self.allow_dict_params = allow_dict_params
|
|
208
|
+
|
|
209
|
+
if allow_list_params is None:
|
|
210
|
+
self.allow_list_params = True
|
|
211
|
+
else:
|
|
212
|
+
self.allow_list_params = allow_list_params
|
|
213
|
+
|
|
214
|
+
if max_concurrent is not None:
|
|
215
|
+
self._effective_max_concurrent = max_concurrent
|
|
216
|
+
else:
|
|
217
|
+
self._effective_max_concurrent = os.cpu_count() or 4
|
|
218
|
+
|
|
219
|
+
self._root_group = MethodGroup()
|
|
220
|
+
self._root_group._name = None
|
|
221
|
+
self._root_group._inject_rpc(self)
|
|
222
|
+
|
|
223
|
+
self._has_direct_root_methods = False
|
|
224
|
+
|
|
225
|
+
def deserialize(self, data: str | bytes) -> Any:
|
|
226
|
+
"""Deserialize incoming JSON to a Python object.
|
|
227
|
+
|
|
228
|
+
Called once per request in handle() / handle_async() before any
|
|
229
|
+
routing or validation. Override to substitute a faster JSON library:
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
>>> import orjson
|
|
233
|
+
>>> class FastRPC(JSONRPC):
|
|
234
|
+
... def deserialize(self, data):
|
|
235
|
+
... return orjson.loads(data)
|
|
236
|
+
|
|
237
|
+
Default: json.loads(data) from the standard library.
|
|
238
|
+
"""
|
|
239
|
+
return json.loads(data)
|
|
240
|
+
|
|
241
|
+
def serialize(self, data: Any, **kwargs: Any) -> str:
|
|
242
|
+
"""Serialize a response dict (or list of dicts for batch) to a JSON string.
|
|
243
|
+
|
|
244
|
+
Override to substitute a faster JSON library:
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
>>> import orjson
|
|
248
|
+
>>> class FastRPC(JSONRPC):
|
|
249
|
+
... def serialize(self, data):
|
|
250
|
+
... return orjson.dumps(data).decode()
|
|
251
|
+
|
|
252
|
+
Default: json.dumps(data) from the standard library.
|
|
253
|
+
"""
|
|
254
|
+
return json.dumps(data, **kwargs)
|
|
255
|
+
|
|
256
|
+
def serialize_result(self, result: Any) -> Any:
|
|
257
|
+
"""Convert method result to a JSON-serializable value.
|
|
258
|
+
|
|
259
|
+
Override to customize serialization of custom types (datetime, Decimal, etc.)
|
|
260
|
+
or to replace the default dataclass conversion with a faster alternative.
|
|
261
|
+
|
|
262
|
+
Default: recursively converts dataclass instances to dicts via dataclasses.asdict().
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
result: Raw method return value
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
JSON-serializable value
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
>>> class MyRPC(JSONRPC):
|
|
272
|
+
... def serialize_result(self, result):
|
|
273
|
+
... if isinstance(result, datetime):
|
|
274
|
+
... return result.isoformat()
|
|
275
|
+
... if isinstance(result, Decimal):
|
|
276
|
+
... return str(result)
|
|
277
|
+
... return super().serialize_result(result)
|
|
278
|
+
"""
|
|
279
|
+
return _dataclass_to_dict(result)
|
|
280
|
+
|
|
281
|
+
def register(self, name: str | None, target: Method | MethodGroup) -> None:
|
|
282
|
+
"""Register method or group.
|
|
283
|
+
|
|
284
|
+
Universal registration supporting:
|
|
285
|
+
- Direct root methods: register('ping', PingMethod())
|
|
286
|
+
- Named groups: register('math', math_group)
|
|
287
|
+
- Explicit root group: register(None, root_group)
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
name: Registration name
|
|
291
|
+
- str (non-empty): register as named group/method
|
|
292
|
+
- None: register as root group (error if root already has methods)
|
|
293
|
+
target: Method instance or MethodGroup instance
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: If name is '' (empty string) or conflicts occur
|
|
297
|
+
TypeError: If target is a class or invalid type
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
# Direct root method (virtual root)
|
|
301
|
+
rpc.register('ping', PingMethod())
|
|
302
|
+
|
|
303
|
+
# Named group
|
|
304
|
+
rpc.register('math', math_group)
|
|
305
|
+
|
|
306
|
+
# Explicit root group (only if no direct methods)
|
|
307
|
+
rpc.register(None, root_group)
|
|
308
|
+
"""
|
|
309
|
+
if name == '':
|
|
310
|
+
raise ValueError(
|
|
311
|
+
'Name cannot be empty string. Use None for root group, or non-empty string for named registration.'
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if isinstance(target, type):
|
|
315
|
+
raise TypeError(
|
|
316
|
+
f"Cannot register class '{target.__name__}'. "
|
|
317
|
+
f"Must register instance: register('{name}', {target.__name__}())"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if name is None:
|
|
321
|
+
if not isinstance(target, MethodGroup):
|
|
322
|
+
raise TypeError(
|
|
323
|
+
f'Cannot register Method directly with name=None. '
|
|
324
|
+
f"Use a non-empty name: register('method_name', {type(target).__name__}())"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if self._has_direct_root_methods:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
'Cannot register explicit root group: root already has directly '
|
|
330
|
+
'registered methods. Clear them first or use named groups.'
|
|
331
|
+
)
|
|
332
|
+
if self._root_group._methods or self._root_group._subgroups:
|
|
333
|
+
raise ValueError('Root group already exists with methods/subgroups')
|
|
334
|
+
|
|
335
|
+
target._inject_rpc(self)
|
|
336
|
+
target._name = None
|
|
337
|
+
self._root_group = target
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
if isinstance(target, Method):
|
|
341
|
+
if '.' in name:
|
|
342
|
+
raise ValueError(f"Method name cannot contain '.': '{name}'")
|
|
343
|
+
|
|
344
|
+
# Fail-fast: check if method is already registered elsewhere
|
|
345
|
+
if hasattr(target, 'rpc'):
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"Method instance '{target.__class__.__name__}' is already registered "
|
|
348
|
+
f'in another JSONRPC/MethodGroup. Create a new instance for each registration.'
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if target.accepts_context and target.context_type is not None and self.context_type is not None:
|
|
352
|
+
if not issubclass(target.context_type, self.context_type):
|
|
353
|
+
raise TypeError(
|
|
354
|
+
f'Cannot register {target.__class__.__name__}: '
|
|
355
|
+
f'method context_type {target.context_type.__name__} must be '
|
|
356
|
+
f'subclass of RPC context_type {self.context_type.__name__}'
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
target.rpc = self
|
|
360
|
+
self._root_group._methods[name] = target
|
|
361
|
+
self._has_direct_root_methods = True
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
if isinstance(target, MethodGroup):
|
|
365
|
+
if '.' in name:
|
|
366
|
+
raise ValueError(f"Group name cannot contain '.': '{name}'")
|
|
367
|
+
|
|
368
|
+
if target.context_type is not None and self.context_type is not None:
|
|
369
|
+
if not issubclass(target.context_type, self.context_type):
|
|
370
|
+
raise TypeError(
|
|
371
|
+
f'Cannot register {target.__class__.__name__}: '
|
|
372
|
+
f'group context_type {target.context_type.__name__} must be '
|
|
373
|
+
f'subclass of RPC context_type {self.context_type.__name__}'
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
target._inject_rpc(self)
|
|
377
|
+
self._root_group.register(name, target)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
raise TypeError(f'Expected Method or MethodGroup instance, got {type(target).__name__}')
|
|
381
|
+
|
|
382
|
+
def unregister(self, path: str) -> None:
|
|
383
|
+
"""Unregister a method or group by name or dotted path.
|
|
384
|
+
|
|
385
|
+
Works for both methods and groups at any nesting level:
|
|
386
|
+
- 'ping' → unregister root-level method or group
|
|
387
|
+
- 'math' → unregister named group
|
|
388
|
+
- 'math.add' → unregister method inside a group
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
path: Name or dotted path to unregister
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
ValueError: If path is empty
|
|
395
|
+
KeyError: If path not found
|
|
396
|
+
"""
|
|
397
|
+
if not path:
|
|
398
|
+
raise ValueError('Path cannot be empty')
|
|
399
|
+
|
|
400
|
+
parts = path.split('.')
|
|
401
|
+
|
|
402
|
+
if len(parts) == 1:
|
|
403
|
+
self._root_group.unregister(path)
|
|
404
|
+
else:
|
|
405
|
+
*group_path, leaf = parts
|
|
406
|
+
group = self._root_group
|
|
407
|
+
for part in group_path:
|
|
408
|
+
subgroup = group.get_subgroup(part)
|
|
409
|
+
if subgroup is None:
|
|
410
|
+
raise KeyError(f"Path '{path}' not found")
|
|
411
|
+
group = subgroup
|
|
412
|
+
group.unregister(leaf)
|
|
413
|
+
|
|
414
|
+
def get_root_group(self) -> MethodGroup:
|
|
415
|
+
"""Get root method group (for OpenAPI generator).
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Root MethodGroup instance
|
|
419
|
+
"""
|
|
420
|
+
return self._root_group
|
|
421
|
+
|
|
422
|
+
def handle(self, raw_data: str | bytes, context: Any = None) -> str | None:
|
|
423
|
+
"""Process incoming JSON-RPC message and return response.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
raw_data: Raw JSON string or bytes
|
|
427
|
+
context: Optional context object passed to methods
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
JSON response string, or None for notifications
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
try:
|
|
434
|
+
data = self.deserialize(raw_data)
|
|
435
|
+
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
436
|
+
err: JSONRPCError = ParseError(f'Invalid JSON: {e}')
|
|
437
|
+
response = build_error_response(err, None, self.version)
|
|
438
|
+
return self.serialize(response)
|
|
439
|
+
|
|
440
|
+
if is_batch(data):
|
|
441
|
+
if not self.allow_batch:
|
|
442
|
+
err = InvalidRequestError('Batch requests not allowed')
|
|
443
|
+
response = build_error_response(err, None, self.version)
|
|
444
|
+
return self.serialize(response)
|
|
445
|
+
return self._handle_batch(data, context=context)
|
|
446
|
+
|
|
447
|
+
return self._handle_single(data, context=context)
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.error('Unhandled exception in handle()', exc_info=True)
|
|
451
|
+
err = InternalError(str(e))
|
|
452
|
+
response = build_error_response(err, None, self.version)
|
|
453
|
+
return self.serialize(response)
|
|
454
|
+
|
|
455
|
+
async def handle_async(self, raw_data: str | bytes, context: Any = None) -> str | None:
|
|
456
|
+
"""Process incoming JSON-RPC message asynchronously.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
raw_data: Raw JSON string or bytes
|
|
460
|
+
context: Optional context object passed to methods
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
JSON response string, or None for notifications
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
try:
|
|
467
|
+
data = self.deserialize(raw_data)
|
|
468
|
+
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
469
|
+
err: JSONRPCError = ParseError(f'Invalid JSON: {e}')
|
|
470
|
+
response = build_error_response(err, None, self.version)
|
|
471
|
+
return self.serialize(response)
|
|
472
|
+
|
|
473
|
+
if is_batch(data):
|
|
474
|
+
if not self.allow_batch:
|
|
475
|
+
err = InvalidRequestError('Batch requests not allowed')
|
|
476
|
+
response = build_error_response(err, None, self.version)
|
|
477
|
+
return self.serialize(response)
|
|
478
|
+
return await self._handle_batch_async(data, context=context)
|
|
479
|
+
|
|
480
|
+
return await self._handle_single_async(data, context=context)
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.error('Unhandled exception in handle_async()', exc_info=True)
|
|
484
|
+
err = InternalError(str(e))
|
|
485
|
+
response = build_error_response(err, None, self.version)
|
|
486
|
+
return self.serialize(response)
|
|
487
|
+
|
|
488
|
+
def _process_single(self, data: dict[str, Any], context: Any = None) -> dict[str, Any] | None:
|
|
489
|
+
"""Process a single request and return response as dict (or None for notifications)."""
|
|
490
|
+
request_id: str | int | None = None
|
|
491
|
+
version = self.version
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
parsed = parse_request(
|
|
495
|
+
data,
|
|
496
|
+
allow_dict_params=self.allow_dict_params,
|
|
497
|
+
allow_list_params=self.allow_list_params,
|
|
498
|
+
)
|
|
499
|
+
# Single dict input always returns single Request (not list)
|
|
500
|
+
request = cast(Request, parsed)
|
|
501
|
+
request_id = request.id
|
|
502
|
+
version = request.version
|
|
503
|
+
|
|
504
|
+
if request.is_notification:
|
|
505
|
+
try:
|
|
506
|
+
self._root_group.dispatch(
|
|
507
|
+
request.method,
|
|
508
|
+
request.params,
|
|
509
|
+
request.id,
|
|
510
|
+
validate_result=self.validate_results,
|
|
511
|
+
context=context,
|
|
512
|
+
)
|
|
513
|
+
except Exception:
|
|
514
|
+
logger.debug('Notification error suppressed (per JSON-RPC spec)', exc_info=True)
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
result = self._root_group.dispatch(
|
|
518
|
+
request.method,
|
|
519
|
+
request.params,
|
|
520
|
+
request.id,
|
|
521
|
+
validate_result=self.validate_results,
|
|
522
|
+
context=context,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
serialized = self.serialize_result(result)
|
|
526
|
+
return build_response(serialized, request_id, version) # type: ignore[arg-type]
|
|
527
|
+
|
|
528
|
+
except JSONRPCError as e:
|
|
529
|
+
return build_error_response(e, request_id, version)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logger.error('Unhandled exception in method dispatch', exc_info=True)
|
|
532
|
+
err = InternalError(str(e))
|
|
533
|
+
return build_error_response(err, request_id, version)
|
|
534
|
+
|
|
535
|
+
async def _process_single_async(self, data: dict[str, Any], context: Any = None) -> dict[str, Any] | None:
|
|
536
|
+
"""Process a single request asynchronously and return response as dict."""
|
|
537
|
+
request_id: str | int | None = None
|
|
538
|
+
version = self.version
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
parsed = parse_request(
|
|
542
|
+
data,
|
|
543
|
+
allow_dict_params=self.allow_dict_params,
|
|
544
|
+
allow_list_params=self.allow_list_params,
|
|
545
|
+
)
|
|
546
|
+
# Single dict input always returns single Request (not list)
|
|
547
|
+
request = cast(Request, parsed)
|
|
548
|
+
request_id = request.id
|
|
549
|
+
version = request.version
|
|
550
|
+
|
|
551
|
+
if request.is_notification:
|
|
552
|
+
try:
|
|
553
|
+
await self._root_group.dispatch_async(
|
|
554
|
+
request.method,
|
|
555
|
+
request.params,
|
|
556
|
+
request.id,
|
|
557
|
+
validate_result=self.validate_results,
|
|
558
|
+
context=context,
|
|
559
|
+
)
|
|
560
|
+
except Exception:
|
|
561
|
+
logger.debug('Notification error suppressed (per JSON-RPC spec)', exc_info=True)
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
result = await self._root_group.dispatch_async(
|
|
565
|
+
request.method,
|
|
566
|
+
request.params,
|
|
567
|
+
request.id,
|
|
568
|
+
validate_result=self.validate_results,
|
|
569
|
+
context=context,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
serialized = self.serialize_result(result)
|
|
573
|
+
return build_response(serialized, request_id, version) # type: ignore[arg-type]
|
|
574
|
+
|
|
575
|
+
except JSONRPCError as e:
|
|
576
|
+
return build_error_response(e, request_id, version)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logger.error('Unhandled exception in async method dispatch', exc_info=True)
|
|
579
|
+
err = InternalError(str(e))
|
|
580
|
+
return build_error_response(err, request_id, version)
|
|
581
|
+
|
|
582
|
+
def _handle_single(self, data: dict[str, Any], context: Any = None) -> str | None:
|
|
583
|
+
"""Handle a single request synchronously."""
|
|
584
|
+
response = self._process_single(data, context=context)
|
|
585
|
+
if response is None:
|
|
586
|
+
return None
|
|
587
|
+
try:
|
|
588
|
+
return self.serialize(response)
|
|
589
|
+
except (TypeError, ValueError) as e:
|
|
590
|
+
logger.error('Serialization error in response', exc_info=True)
|
|
591
|
+
request_id = response.get('id')
|
|
592
|
+
err = InternalError(str(e))
|
|
593
|
+
error_response = build_error_response(err, request_id, self.version)
|
|
594
|
+
return self.serialize(error_response)
|
|
595
|
+
|
|
596
|
+
async def _handle_single_async(self, data: dict[str, Any], context: Any = None) -> str | None:
|
|
597
|
+
"""Handle a single request asynchronously."""
|
|
598
|
+
response = await self._process_single_async(data, context=context)
|
|
599
|
+
if response is None:
|
|
600
|
+
return None
|
|
601
|
+
try:
|
|
602
|
+
return self.serialize(response)
|
|
603
|
+
except (TypeError, ValueError) as e:
|
|
604
|
+
logger.error('Serialization error in response', exc_info=True)
|
|
605
|
+
request_id = response.get('id')
|
|
606
|
+
err = InternalError(str(e))
|
|
607
|
+
error_response = build_error_response(err, request_id, self.version)
|
|
608
|
+
return self.serialize(error_response)
|
|
609
|
+
|
|
610
|
+
def _handle_batch(self, data: list[Any], context: Any = None) -> str | None:
|
|
611
|
+
"""Handle batch request synchronously."""
|
|
612
|
+
if not data:
|
|
613
|
+
error = InvalidRequestError('Empty batch request')
|
|
614
|
+
response = build_error_response(error, None, self.version)
|
|
615
|
+
return self.serialize(response)
|
|
616
|
+
|
|
617
|
+
if self.max_batch != -1 and len(data) > self.max_batch:
|
|
618
|
+
error = InvalidRequestError(
|
|
619
|
+
f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
|
|
620
|
+
)
|
|
621
|
+
response = build_error_response(error, None, self.version)
|
|
622
|
+
return self.serialize(response)
|
|
623
|
+
|
|
624
|
+
responses = []
|
|
625
|
+
for item in data:
|
|
626
|
+
result = self._process_single(item, context=context)
|
|
627
|
+
if result is not None:
|
|
628
|
+
responses.append(result)
|
|
629
|
+
|
|
630
|
+
if not responses:
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
return self.serialize(responses)
|
|
635
|
+
except (TypeError, ValueError) as e:
|
|
636
|
+
logger.error('Serialization error in batch response', exc_info=True)
|
|
637
|
+
err = InternalError(str(e))
|
|
638
|
+
error_response = build_error_response(err, None, self.version)
|
|
639
|
+
return self.serialize(error_response)
|
|
640
|
+
|
|
641
|
+
async def _handle_batch_async(self, data: list[Any], context: Any = None) -> str | None:
|
|
642
|
+
"""Handle batch request asynchronously (concurrent execution)."""
|
|
643
|
+
if not data:
|
|
644
|
+
error = InvalidRequestError('Empty batch request')
|
|
645
|
+
response = build_error_response(error, None, self.version)
|
|
646
|
+
return self.serialize(response)
|
|
647
|
+
|
|
648
|
+
if self.max_batch != -1 and len(data) > self.max_batch:
|
|
649
|
+
error = InvalidRequestError(
|
|
650
|
+
f'Batch too large: {len(data)} requests, maximum is {self.max_batch}'
|
|
651
|
+
)
|
|
652
|
+
response = build_error_response(error, None, self.version)
|
|
653
|
+
return self.serialize(response)
|
|
654
|
+
|
|
655
|
+
limit = self._effective_max_concurrent
|
|
656
|
+
if limit == -1:
|
|
657
|
+
results = await asyncio.gather(*[self._process_single_async(item, context=context) for item in data])
|
|
658
|
+
else:
|
|
659
|
+
sem = asyncio.Semaphore(limit)
|
|
660
|
+
|
|
661
|
+
async def _limited(item: Any) -> Any:
|
|
662
|
+
async with sem:
|
|
663
|
+
return await self._process_single_async(item, context=context)
|
|
664
|
+
|
|
665
|
+
results = await asyncio.gather(*[_limited(item) for item in data])
|
|
666
|
+
responses = [r for r in results if r is not None]
|
|
667
|
+
|
|
668
|
+
if not responses:
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
try:
|
|
672
|
+
return self.serialize(responses)
|
|
673
|
+
except (TypeError, ValueError) as e:
|
|
674
|
+
logger.error('Serialization error in batch response', exc_info=True)
|
|
675
|
+
err = InternalError(str(e))
|
|
676
|
+
error_response = build_error_response(err, None, self.version)
|
|
677
|
+
return self.serialize(error_response)
|
|
678
|
+
|
|
679
|
+
def call_method(
|
|
680
|
+
self,
|
|
681
|
+
method: str,
|
|
682
|
+
params: list[Any] | dict[str, Any] | None = None,
|
|
683
|
+
id: str | int | None = None,
|
|
684
|
+
validate_result: bool | None = None,
|
|
685
|
+
context: Any = None,
|
|
686
|
+
) -> Any:
|
|
687
|
+
"""Call a registered method directly (no JSON serialization).
|
|
688
|
+
|
|
689
|
+
Use this for internal method-to-method calls.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
method: Full method name (e.g., "math.add")
|
|
693
|
+
params: Method parameters
|
|
694
|
+
id: Request ID (optional)
|
|
695
|
+
validate_result: Override global validate_results setting.
|
|
696
|
+
None = use global setting, True/False = override.
|
|
697
|
+
context: Optional context object passed to method
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
Raw method result
|
|
701
|
+
|
|
702
|
+
Raises:
|
|
703
|
+
MethodNotFoundError: If method doesn't exist
|
|
704
|
+
InvalidParamsError: If params validation fails
|
|
705
|
+
InvalidResultError: If result validation fails
|
|
706
|
+
"""
|
|
707
|
+
should_validate = validate_result if validate_result is not None else self.validate_results
|
|
708
|
+
return self._root_group.dispatch(method, params, id, validate_result=should_validate, context=context)
|
|
709
|
+
|
|
710
|
+
async def call_method_async(
|
|
711
|
+
self,
|
|
712
|
+
method: str,
|
|
713
|
+
params: list[Any] | dict[str, Any] | None = None,
|
|
714
|
+
id: str | int | None = None,
|
|
715
|
+
validate_result: bool | None = None,
|
|
716
|
+
context: Any = None,
|
|
717
|
+
) -> Any:
|
|
718
|
+
"""Call a registered method asynchronously.
|
|
719
|
+
|
|
720
|
+
Use this for internal method-to-method calls with async methods.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
method: Full method name (e.g., "math.add")
|
|
724
|
+
params: Method parameters
|
|
725
|
+
id: Request ID (optional)
|
|
726
|
+
validate_result: Override global validate_results setting.
|
|
727
|
+
None = use global setting, True/False = override.
|
|
728
|
+
context: Optional context object passed to method
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
Raw method result
|
|
732
|
+
|
|
733
|
+
Raises:
|
|
734
|
+
MethodNotFoundError: If method doesn't exist
|
|
735
|
+
InvalidParamsError: If params validation fails
|
|
736
|
+
InvalidResultError: If result validation fails
|
|
737
|
+
"""
|
|
738
|
+
should_validate = validate_result if validate_result is not None else self.validate_results
|
|
739
|
+
return await self._root_group.dispatch_async(
|
|
740
|
+
method, params, id, validate_result=should_validate, context=context
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
def list_methods(self) -> list[str]:
|
|
744
|
+
"""List all registered method names (with full paths).
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Sorted list of method paths (e.g., ["ping", "math.add", "sudo.user.delete"])
|
|
748
|
+
"""
|
|
749
|
+
return sorted(self._root_group.list_methods(recursive=True))
|
|
750
|
+
|
|
751
|
+
def get_method(self, path: str) -> 'Any | None':
|
|
752
|
+
"""Get method by full path.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
path: Full method path (e.g., "math.add")
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
Method instance or None if not found
|
|
759
|
+
"""
|
|
760
|
+
try:
|
|
761
|
+
_, method = self._root_group.resolve_path(path)
|
|
762
|
+
return method
|
|
763
|
+
except (KeyError, MethodNotFoundError):
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
def method(self, name_or_func: str | Callable[..., Any] | None = None) -> Callable[..., Any]:
|
|
767
|
+
"""Decorator to register a function as a JSON-RPC method (prototyping only).
|
|
768
|
+
|
|
769
|
+
This decorator provides a quick way to register functions as JSON-RPC
|
|
770
|
+
methods for prototyping and simple use cases. It automatically:
|
|
771
|
+
- Creates a dataclass from function parameters
|
|
772
|
+
- Wraps the function in a Method subclass
|
|
773
|
+
- Registers the method with the RPC instance
|
|
774
|
+
|
|
775
|
+
LIMITATIONS (use Method subclass for production):
|
|
776
|
+
- No context support
|
|
777
|
+
- No group registration (root level only)
|
|
778
|
+
- No custom middleware
|
|
779
|
+
- Less control over parameter validation
|
|
780
|
+
- No notification methods (-> None return type not supported)
|
|
781
|
+
- PROTOTYPING ONLY - not recommended for production use
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
name_or_func: Optional method name (defaults to function.__name__)
|
|
785
|
+
Can be used as @rpc.method or @rpc.method("custom_name")
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
The original function (for testing and introspection)
|
|
789
|
+
|
|
790
|
+
Raises:
|
|
791
|
+
TypeError: If function lacks type hints or has unsupported parameters
|
|
792
|
+
ValueError: If method name contains '.' or is invalid
|
|
793
|
+
|
|
794
|
+
Example - Basic usage:
|
|
795
|
+
>>> rpc = JSONRPC()
|
|
796
|
+
>>>
|
|
797
|
+
>>> @rpc.method
|
|
798
|
+
>>> def add(a: int, b: int) -> int:
|
|
799
|
+
>>> '''Add two numbers.'''
|
|
800
|
+
>>> return a + b
|
|
801
|
+
>>>
|
|
802
|
+
>>> # Use via JSON-RPC
|
|
803
|
+
>>> rpc.handle('{"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}')
|
|
804
|
+
>>> '{"jsonrpc": "2.0", "result": 3, "id": 1}'
|
|
805
|
+
>>>
|
|
806
|
+
>>> # Original function still works
|
|
807
|
+
>>> add(1, 2)
|
|
808
|
+
>>> 3
|
|
809
|
+
|
|
810
|
+
Example - Custom name:
|
|
811
|
+
>>> @rpc.method("custom_add")
|
|
812
|
+
>>> def my_add(a: int, b: int) -> int:
|
|
813
|
+
>>> return a + b
|
|
814
|
+
|
|
815
|
+
Example - Default values:
|
|
816
|
+
>>> @rpc.method
|
|
817
|
+
>>> def greet(name: str, greeting: str = "Hello") -> str:
|
|
818
|
+
>>> return f"{greeting}, {name}!"
|
|
819
|
+
|
|
820
|
+
Example - Async function:
|
|
821
|
+
>>> @rpc.method
|
|
822
|
+
>>> async def fetch(url: str) -> dict:
|
|
823
|
+
>>> return await http_get(url)
|
|
824
|
+
|
|
825
|
+
For production use, create Method subclasses instead:
|
|
826
|
+
>>> class Add(Method):
|
|
827
|
+
>>> def execute(self, params: AddParams) -> int:
|
|
828
|
+
>>> return params.a + params.b
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
832
|
+
if isinstance(name_or_func, str):
|
|
833
|
+
method_name = name_or_func
|
|
834
|
+
else:
|
|
835
|
+
method_name = func.__name__
|
|
836
|
+
|
|
837
|
+
# Decorator only supports JSON-RPC 2.0 (prototyping feature)
|
|
838
|
+
if self.version != '2.0':
|
|
839
|
+
raise ValueError(
|
|
840
|
+
f'The @rpc.method decorator is only available for JSON-RPC 2.0 (current version: {self.version}). '
|
|
841
|
+
f'This restriction is intentional - the decorator is designed exclusively for prototyping. '
|
|
842
|
+
f'For JSON-RPC 1.0 or production use, create Method subclasses instead:\n\n'
|
|
843
|
+
f' class {method_name.title()}(Method):\n'
|
|
844
|
+
f' def execute(self, params: ParamsType) -> ResultType:\n'
|
|
845
|
+
f' ...'
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if not method_name or method_name == '':
|
|
849
|
+
raise ValueError(
|
|
850
|
+
"Method name cannot be empty. Use function name or provide explicit name: @rpc.method('name')"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
if '.' in method_name:
|
|
854
|
+
raise ValueError(
|
|
855
|
+
f"Method name '{method_name}' cannot contain '.'. "
|
|
856
|
+
f'The @rpc.method decorator only supports root-level registration. '
|
|
857
|
+
f'Groups are not supported in this simplified API. '
|
|
858
|
+
f'For group registration, create Method subclasses and use MethodGroup.'
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
sig = inspect.signature(func)
|
|
862
|
+
hints = get_type_hints(func)
|
|
863
|
+
|
|
864
|
+
_validate_decorator_function(func, sig, hints)
|
|
865
|
+
|
|
866
|
+
params_type = _create_params_dataclass(func.__name__, sig, hints)
|
|
867
|
+
|
|
868
|
+
return_type = hints['return']
|
|
869
|
+
|
|
870
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
871
|
+
MethodClass = _create_method_class(func, params_type, return_type, is_async)
|
|
872
|
+
|
|
873
|
+
method_instance = MethodClass()
|
|
874
|
+
self.register(method_name, method_instance)
|
|
875
|
+
|
|
876
|
+
return func
|
|
877
|
+
|
|
878
|
+
if callable(name_or_func):
|
|
879
|
+
return decorator(name_or_func)
|
|
880
|
+
else:
|
|
881
|
+
return decorator
|