jsonrpcserver 5.0.10__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.
@@ -0,0 +1,460 @@
1
+ """Dispatcher - does the hard work of this library: parses, validates and dispatches
2
+ requests, providing responses.
3
+ """
4
+
5
+ import logging
6
+ import reprlib
7
+ from functools import partial
8
+ from inspect import iscoroutine, signature
9
+ from itertools import starmap
10
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union, cast
11
+
12
+ from oslash.either import Either, Left, Right
13
+
14
+ from .exceptions import JsonRpcError
15
+ from .methods import AnyMethod, MethodsArgument
16
+
17
+ # Importable from here in 5.0.9. The "as" form marks a re-export.
18
+ from .methods import Method as Method
19
+ from .methods import Methods as Methods
20
+ from .request import Request
21
+ from .response import (
22
+ ErrorResponse,
23
+ InvalidRequestResponse,
24
+ ParseErrorResponse,
25
+ Response,
26
+ ServerErrorResponse,
27
+ SuccessResponse,
28
+ )
29
+ from .result import (
30
+ ErrorResult,
31
+ InternalErrorResult,
32
+ InvalidParamsResult,
33
+ MethodNotFoundResult,
34
+ Result,
35
+ SuccessResult,
36
+ )
37
+ from .sentinels import NOCONTEXT, NODATA, NOID, Sentinel
38
+ from .utils import compose, identity, make_list, unwrap
39
+
40
+ Deserialized = Union[Dict[str, Any], List[Dict[str, Any]]]
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Returned by dispatch_member for a notification. Not None, because post_process may
45
+ # legitimately return None.
46
+ NORESPONSE = Sentinel("NoResponse")
47
+
48
+
49
+ def extract_list(is_batch: bool, responses: Iterable[Any]) -> Any:
50
+ """This is the inverse of make_list. Here we extract a response back out of the list
51
+ if it wasn't a batch request originally. Also applies a JSON-RPC rule: we do not
52
+ respond to batches of notifications.
53
+
54
+ Args:
55
+ is_batch: True if the original request was a batch.
56
+ responses: Iterable of responses.
57
+
58
+ Returns: A single response, a batch of responses, or None (returns None to a
59
+ notification or batch of notifications, to indicate we should not respond).
60
+ The responses may have been through post_process, so they can be any type.
61
+ """
62
+ # Need to materialize the iterable here to determine if it's empty. At least we're
63
+ # at the end of processing (also need a list, not a generator, to serialize a batch
64
+ # response with json.dumps).
65
+ response_list = list(responses)
66
+ # Responses have been removed, so in the case of either a single notification or a
67
+ # batch of only notifications, return None
68
+ if len(response_list) == 0:
69
+ return None
70
+ # For batches containing at least one non-notification, return the list
71
+ if is_batch:
72
+ return response_list
73
+ # For single requests, extract it back from the list (there will be only one).
74
+ return response_list[0]
75
+
76
+
77
+ def to_response(request: Request, result: Result) -> Response:
78
+ """Maps a Request plus a Result to a Response. A Response is just a Result plus the
79
+ id from the original Request.
80
+
81
+ Raises: AssertionError if the request is a notification. Notifications can't be
82
+ responded to. If a notification is given and AssertionError is raised, we should
83
+ respond with Server Error, because notifications should have been removed by
84
+ this stage.
85
+
86
+ Returns: A Response.
87
+ """
88
+ # Not an assert statement, because python -O would remove it.
89
+ if request.id is NOID:
90
+ raise AssertionError("Can't respond to a notification")
91
+ if isinstance(result, Left):
92
+ return Left(ErrorResponse(**result._error._asdict(), id=request.id))
93
+ success = cast("Right[SuccessResult, ErrorResult]", result)._value
94
+ return Right(SuccessResponse(**success._asdict(), id=request.id))
95
+
96
+
97
+ def extract_args(request: Request, context: Any) -> List[Any]:
98
+ """Extracts the positional arguments from the request.
99
+
100
+ If a context object is given, it's added as the first argument.
101
+
102
+ Returns: A list containing the positional arguments.
103
+ """
104
+ params = request.params if isinstance(request.params, list) else []
105
+ return [context, *params] if context is not NOCONTEXT else params
106
+
107
+
108
+ def extract_kwargs(request: Request) -> Dict[str, Any]:
109
+ """Extracts the keyword arguments from the reqeust.
110
+
111
+ Returns: A dict containing the keyword arguments.
112
+ """
113
+ return request.params if isinstance(request.params, dict) else {}
114
+
115
+
116
+ class InvalidResultError(AssertionError):
117
+ """A method returned something other than Success(...) or Error(...)."""
118
+
119
+ def __init__(self, result: object):
120
+ super().__init__(
121
+ f"The method did not return a valid Result (returned {result!r}). "
122
+ "Return Success(value) or Error(code, message)."
123
+ )
124
+ self.result = result
125
+
126
+
127
+ def validate_result(result: object) -> None:
128
+ """Validate the return value from a method.
129
+
130
+ Raises an InvalidResultError, a subclass of AssertionError, if the result returned
131
+ from a method is invalid.
132
+
133
+ Returns: None
134
+ """
135
+ # Not an assert statement, because python -O would remove it.
136
+ returned = result # Not narrowed by the checks below.
137
+ error: object = getattr(result, "_error", None)
138
+ value: object = getattr(result, "_value", None)
139
+ if not (
140
+ (isinstance(result, Left) and isinstance(error, ErrorResult))
141
+ or (isinstance(result, Right) and isinstance(value, SuccessResult))
142
+ ):
143
+ raise InvalidResultError(returned)
144
+
145
+
146
+ def log_invalid_result(
147
+ method_name: str, exc: InvalidResultError, log: logging.Logger
148
+ ) -> None:
149
+ """Log a method that returned a plain value, which 4.x allowed, without a traceback.
150
+
151
+ The traceback would only point into jsonrpcserver, so the message says what to
152
+ change instead.
153
+ """
154
+ log.error(
155
+ "Method %r returned %s, which is not a Result, so the client got an "
156
+ "Internal error. Return Success(value) or Error(code, message). Since 5.0 a "
157
+ "plain return value is not enough: "
158
+ "https://bensynapse.github.io/jsonrpcserver/migration/",
159
+ method_name,
160
+ reprlib.repr(exc.result),
161
+ )
162
+
163
+
164
+ def exception_data(exc: BaseException, debug: bool) -> Any:
165
+ """The "data" member of the error response for an unexpected exception.
166
+
167
+ Exception messages often hold things a client must not see, like connection
168
+ strings, file paths and SQL, so they're only included in debug mode.
169
+ """
170
+ return str(exc) if debug else NODATA
171
+
172
+
173
+ def call(
174
+ request: Request, context: Any, method: AnyMethod, debug: bool = False
175
+ ) -> Result:
176
+ """Call the method.
177
+
178
+ Handles any exceptions raised in the method, being sure to return an Error response.
179
+ The exception is logged. Its message goes in the response only if debug is True.
180
+
181
+ Returns: A Result.
182
+ """
183
+ try:
184
+ result = method(*extract_args(request, context), **extract_kwargs(request))
185
+ if iscoroutine(result):
186
+ result.close() # Avoids a "coroutine was never awaited" warning.
187
+ raise TypeError(
188
+ f"Method {request.method!r} is async. Use async_dispatch to call "
189
+ "async methods."
190
+ )
191
+ # validate_result raises AssertionError if the return value is not a valid
192
+ # Result, which should respond with Internal Error because its a problem in the
193
+ # method.
194
+ validate_result(result)
195
+ # Raising JsonRpcError inside the method is an alternative way of returning an error
196
+ # response.
197
+ except JsonRpcError as exc:
198
+ return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data))
199
+ except InvalidResultError as exc:
200
+ log_invalid_result(request.method, exc, logger)
201
+ return Left(InternalErrorResult(exception_data(exc, debug)))
202
+ # Any other uncaught exception inside method - internal error.
203
+ except Exception as exc:
204
+ logger.exception("Method %r raised an exception", request.method)
205
+ return Left(InternalErrorResult(exception_data(exc, debug)))
206
+ # validate_result has checked it.
207
+ return cast(Result, result)
208
+
209
+
210
+ def validate_args(
211
+ request: Request, context: Any, func: AnyMethod
212
+ ) -> Either[AnyMethod, ErrorResult]:
213
+ """Ensure the method can be called with the arguments given.
214
+
215
+ Returns: Either the function to be called, or an Invalid Params error result.
216
+ """
217
+ try:
218
+ sig = signature(func)
219
+ except ValueError:
220
+ # Some builtins have no signature we can inspect. Call them anyway, and let
221
+ # call() deal with a bad argument list.
222
+ return Right(func)
223
+ try:
224
+ sig.bind(*extract_args(request, context), **extract_kwargs(request))
225
+ except TypeError as exc:
226
+ return Left(InvalidParamsResult(str(exc)))
227
+ return Right(func)
228
+
229
+
230
+ def get_method(
231
+ methods: MethodsArgument, method_name: str
232
+ ) -> Either[AnyMethod, ErrorResult]:
233
+ """Get the requested method from the methods dict.
234
+
235
+ Returns: Either the function to be called, or a Method Not Found result.
236
+ """
237
+ try:
238
+ return Right(methods[method_name])
239
+ except KeyError:
240
+ return Left(MethodNotFoundResult(method_name))
241
+
242
+
243
+ def dispatch_request(
244
+ methods: MethodsArgument, context: Any, request: Request, debug: bool = False
245
+ ) -> Tuple[Request, Result]:
246
+ """Get the method, validates the arguments and calls the method.
247
+
248
+ Returns: A tuple containing the Result of the method, along with the original
249
+ Request. We need the ids from the original request to remove notifications
250
+ before responding, and create a Response.
251
+ """
252
+ return (
253
+ request,
254
+ get_method(methods, request.method)
255
+ .bind(partial(validate_args, request, context))
256
+ .bind(partial(call, request, context, debug=debug)),
257
+ )
258
+
259
+
260
+ def create_request(request: Dict[str, Any]) -> Request:
261
+ """Create a Request namedtuple from a dict."""
262
+ return Request(
263
+ request["method"], request.get("params", []), request.get("id", NOID)
264
+ )
265
+
266
+
267
+ def not_notification(request_result: Any) -> bool:
268
+ """True if the request was not a notification.
269
+
270
+ Used to filter out notifications from the list of responses.
271
+ """
272
+ return request_result[0].id is not NOID
273
+
274
+
275
+ def dispatch_deserialized(
276
+ methods: MethodsArgument,
277
+ context: Any,
278
+ post_process: Callable[[Response], Any],
279
+ deserialized: Deserialized,
280
+ debug: bool = False,
281
+ ) -> Any:
282
+ """This is simply continuing the pipeline from dispatch_to_response_pure. It exists
283
+ only to be an abstraction, otherwise that function is doing too much. It continues
284
+ on from the request string having been parsed and validated.
285
+
286
+ Returns: A Response, a list of Responses, or None. If post_process is passed, it's
287
+ applied to the Response(s).
288
+ """
289
+ results = map(
290
+ compose(
291
+ partial(dispatch_request, methods, context, debug=debug), create_request
292
+ ),
293
+ make_list(deserialized),
294
+ )
295
+ responses = starmap(to_response, filter(not_notification, results))
296
+ return extract_list(isinstance(deserialized, list), map(post_process, responses))
297
+
298
+
299
+ def validate_request(
300
+ validator: Callable[[Deserialized], object], request: Deserialized
301
+ ) -> Either[Deserialized, ErrorResponse]:
302
+ """Validate the request against a JSON-RPC schema.
303
+
304
+ Ensures the parsed request is valid JSON-RPC.
305
+
306
+ Returns: Either the same request passed in or an Invalid request response.
307
+ """
308
+ try:
309
+ validator(request)
310
+ # Since the validator is unknown, the specific exception that will be raised is also
311
+ # unknown. Any exception raised we assume the request is invalid and return an
312
+ # "invalid request" response.
313
+ except Exception:
314
+ return Left(InvalidRequestResponse("The request failed schema validation"))
315
+ return Right(request)
316
+
317
+
318
+ def deserialize_request(
319
+ deserializer: Callable[[str], Deserialized], request: str
320
+ ) -> Either[Deserialized, ErrorResponse]:
321
+ """Parse the JSON request string.
322
+
323
+ Returns: Either the deserialized request or a "Parse Error" response.
324
+ """
325
+ try:
326
+ return Right(deserializer(request))
327
+ # Since the deserializer is unknown, the specific exception that will be raised is
328
+ # also unknown. Any exception raised we assume the request is invalid, return a
329
+ # parse error response.
330
+ except Exception as exc:
331
+ return Left(ParseErrorResponse(str(exc)))
332
+
333
+
334
+ def dispatch_single(
335
+ validator: Callable[[Deserialized], object],
336
+ methods: MethodsArgument,
337
+ context: Any,
338
+ deserialized: Deserialized,
339
+ debug: bool = False,
340
+ ) -> Optional[Response]:
341
+ """Validate and dispatch one member of a batch."""
342
+ if isinstance(deserialized, list):
343
+ return Left(InvalidRequestResponse("The request failed schema validation"))
344
+ result = validate_request(validator, deserialized)
345
+ if isinstance(result, Left):
346
+ return Left(result._error)
347
+ return cast(
348
+ Optional[Response],
349
+ dispatch_deserialized(methods, context, identity, unwrap(result), debug=debug),
350
+ )
351
+
352
+
353
+ def BatchTooLargeResponse(size: int, max_batch_size: int) -> ErrorResponse:
354
+ """The single Invalid request response for a batch over the size limit."""
355
+ return InvalidRequestResponse(
356
+ f"The batch has {size} requests. The limit is {max_batch_size}."
357
+ )
358
+
359
+
360
+ def check_max_batch_size(max_batch_size: object) -> None:
361
+ """Raise ValueError for a max_batch_size that makes no sense."""
362
+ if max_batch_size is not None and (
363
+ isinstance(max_batch_size, bool)
364
+ or not isinstance(max_batch_size, int)
365
+ or max_batch_size < 1
366
+ ):
367
+ raise ValueError(
368
+ f"max_batch_size must be a positive int or None, not {max_batch_size!r}"
369
+ )
370
+
371
+
372
+ def member_id(member: Any) -> Any:
373
+ """The id of a batch member, or None if it has none we can use.
374
+
375
+ Used for the error response when something unexpected goes wrong with one member
376
+ of a batch.
377
+ """
378
+ if isinstance(member, dict):
379
+ id_ = cast(Dict[str, Any], member).get("id")
380
+ if id_ is None or isinstance(id_, (str, int, float)):
381
+ return id_
382
+ return None
383
+
384
+
385
+ def dispatch_member(
386
+ validator: Callable[[Deserialized], object],
387
+ methods: MethodsArgument,
388
+ context: Any,
389
+ post_process: Callable[[Response], Any],
390
+ member: Any,
391
+ debug: bool = False,
392
+ ) -> Any:
393
+ """Dispatch one member of a batch, keeping any failure inside that member.
394
+
395
+ Returns: The post-processed response, or NORESPONSE for a notification.
396
+ """
397
+ response: Optional[Response]
398
+ try:
399
+ response = dispatch_single(validator, methods, context, member, debug=debug)
400
+ except Exception as exc:
401
+ logger.exception("Error while dispatching a member of a batch")
402
+ response = Left(
403
+ ServerErrorResponse(exception_data(exc, debug), member_id(member))
404
+ )
405
+ return NORESPONSE if response is None else post_process(response)
406
+
407
+
408
+ def dispatch_to_response_pure(
409
+ *,
410
+ deserializer: Callable[[str], Deserialized],
411
+ validator: Callable[[Deserialized], object],
412
+ methods: MethodsArgument,
413
+ context: Any,
414
+ post_process: Callable[[Response], Any],
415
+ request: str,
416
+ debug: bool = False,
417
+ max_batch_size: Optional[int] = None,
418
+ ) -> Any:
419
+ """A function from JSON-RPC request string to Response namedtuple(s), (yet to be
420
+ serialized to json).
421
+
422
+ If debug is True, error responses for unexpected exceptions include the exception
423
+ message in "data". Leave it off in production.
424
+
425
+ If max_batch_size is given, a batch with more members than that gets a single
426
+ Invalid request response, and none of its members are dispatched.
427
+
428
+ Returns: A single Response, a list of Responses, or None, each passed through
429
+ post_process. None is given for notifications or batches of notifications, to
430
+ indicate that we should not respond.
431
+ """
432
+ try:
433
+ parsed = deserialize_request(deserializer, request)
434
+ if isinstance(parsed, Left):
435
+ return post_process(Left(parsed._error))
436
+ deserialized = unwrap(parsed)
437
+ if isinstance(deserialized, list) and deserialized:
438
+ if max_batch_size is not None and len(deserialized) > max_batch_size:
439
+ return post_process(
440
+ Left(BatchTooLargeResponse(len(deserialized), max_batch_size))
441
+ )
442
+ responses = [
443
+ dispatch_member(
444
+ validator, methods, context, post_process, member, debug=debug
445
+ )
446
+ for member in deserialized
447
+ ]
448
+ return extract_list(
449
+ True, [response for response in responses if response is not NORESPONSE]
450
+ )
451
+ validated = validate_request(validator, deserialized)
452
+ if isinstance(validated, Left):
453
+ return post_process(Left(validated._error))
454
+ return dispatch_deserialized(
455
+ methods, context, post_process, unwrap(validated), debug=debug
456
+ )
457
+ except Exception as exc:
458
+ # There was an error with the jsonrpcserver library.
459
+ logger.exception("Error while dispatching the request")
460
+ return post_process(Left(ServerErrorResponse(exception_data(exc, debug), None)))
@@ -0,0 +1,35 @@
1
+ """The exception a method can raise to send an error response."""
2
+
3
+ from typing import Any
4
+
5
+ from .sentinels import NODATA
6
+ from .utils import warn_if_invalid_error
7
+
8
+
9
+ class JsonRpcError(Exception):
10
+ """Raise it in a method, or in anything a method calls, to send an error response.
11
+
12
+ It's the same as returning `Error(code, message, data)`, but it works from deep
13
+ inside other functions. Like `Error`, it's sent as it is, whatever `debug` is.
14
+
15
+ Args:
16
+ code: The error code, an integer.
17
+ message: A short description of the error, as a string.
18
+ data: Extra information for the client. If it isn't given, the response has
19
+ no `data` member.
20
+
21
+ Warns:
22
+ UserWarning: If `code` isn't an int or `message` isn't a str. New in 5.0.10.
23
+
24
+ Example:
25
+ >>> from jsonrpcserver import Result, dispatch_to_serializable
26
+ >>> def withdraw(amount: int) -> Result:
27
+ ... raise JsonRpcError(2, "Insufficient funds", {"balance": 10})
28
+ >>> request = '{"jsonrpc": "2.0", "method": "withdraw", "params": [5], "id": 1}'
29
+ >>> dispatch_to_serializable(request, {"withdraw": withdraw})["error"]
30
+ {'code': 2, 'message': 'Insufficient funds', 'data': {'balance': 10}}
31
+ """
32
+
33
+ def __init__(self, code: int, message: str, data: Any = NODATA):
34
+ warn_if_invalid_error(code, message, stacklevel=2)
35
+ self.code, self.message, self.data = (code, message, data)