16 changed files with 243 additions and 1113 deletions
@ -1,806 +0,0 @@ |
|||||
# Copyright (c) 2016-2017, Neil Booth |
|
||||
# |
|
||||
# All rights reserved. |
|
||||
# |
|
||||
# The MIT License (MIT) |
|
||||
# |
|
||||
# Permission is hereby granted, free of charge, to any person obtaining |
|
||||
# a copy of this software and associated documentation files (the |
|
||||
# "Software"), to deal in the Software without restriction, including |
|
||||
# without limitation the rights to use, copy, modify, merge, publish, |
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to |
|
||||
# permit persons to whom the Software is furnished to do so, subject to |
|
||||
# the following conditions: |
|
||||
# |
|
||||
# The above copyright notice and this permission notice shall be |
|
||||
# included in all copies or substantial portions of the Software. |
|
||||
# |
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|
||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
|
||||
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
|
||||
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
|
||||
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
|
||||
|
|
||||
'''Classes for acting as a peer over a transport and speaking the JSON |
|
||||
RPC versions 1.0 and 2.0. |
|
||||
|
|
||||
JSONSessionBase can use an arbitrary transport. |
|
||||
JSONSession integrates asyncio.Protocol to provide the transport. |
|
||||
''' |
|
||||
|
|
||||
import asyncio |
|
||||
import collections |
|
||||
import inspect |
|
||||
import json |
|
||||
import numbers |
|
||||
import time |
|
||||
import traceback |
|
||||
|
|
||||
import lib.util as util |
|
||||
|
|
||||
|
|
||||
class RPCError(Exception): |
|
||||
'''RPC handlers raise this error.''' |
|
||||
def __init__(self, msg, code=-1, **kw_args): |
|
||||
super().__init__(**kw_args) |
|
||||
self.msg = msg |
|
||||
self.code = code |
|
||||
|
|
||||
|
|
||||
class JSONRPC(object): |
|
||||
'''Base class of JSON RPC versions.''' |
|
||||
|
|
||||
# See http://www.jsonrpc.org/specification |
|
||||
PARSE_ERROR = -32700 |
|
||||
INVALID_REQUEST = -32600 |
|
||||
METHOD_NOT_FOUND = -32601 |
|
||||
INVALID_ARGS = -32602 |
|
||||
INTERNAL_ERROR = -32603 |
|
||||
|
|
||||
# Codes for this library |
|
||||
INVALID_RESPONSE = -100 |
|
||||
ERROR_CODE_UNAVAILABLE = -101 |
|
||||
REQUEST_TIMEOUT = -102 |
|
||||
FATAL_ERROR = -103 |
|
||||
|
|
||||
ID_TYPES = (type(None), str, numbers.Number) |
|
||||
HAS_BATCHES = False |
|
||||
|
|
||||
@classmethod |
|
||||
def canonical_error(cls, error): |
|
||||
'''Convert an error to a JSON RPC 2.0 error. |
|
||||
|
|
||||
Handlers then only have a single form of error to deal with. |
|
||||
''' |
|
||||
if isinstance(error, int): |
|
||||
error = {'code': error} |
|
||||
elif isinstance(error, str): |
|
||||
error = {'message': error} |
|
||||
elif not isinstance(error, dict): |
|
||||
error = {'data': error} |
|
||||
error['code'] = error.get('code', JSONRPC.ERROR_CODE_UNAVAILABLE) |
|
||||
error['message'] = error.get('message', 'error message unavailable') |
|
||||
return error |
|
||||
|
|
||||
@classmethod |
|
||||
def timeout_error(cls): |
|
||||
return {'message': 'request timed out', |
|
||||
'code': JSONRPC.REQUEST_TIMEOUT} |
|
||||
|
|
||||
|
|
||||
class JSONRPCv1(JSONRPC): |
|
||||
'''JSON RPC version 1.0.''' |
|
||||
|
|
||||
@classmethod |
|
||||
def request_payload(cls, id_, method, params=None): |
|
||||
'''JSON v1 request payload. Params is mandatory.''' |
|
||||
return {'method': method, 'params': params or [], 'id': id_} |
|
||||
|
|
||||
@classmethod |
|
||||
def notification_payload(cls, method, params=None): |
|
||||
'''JSON v1 notification payload. Params and id are mandatory.''' |
|
||||
return {'method': method, 'params': params or [], 'id': None} |
|
||||
|
|
||||
@classmethod |
|
||||
def response_payload(cls, result, id_): |
|
||||
'''JSON v1 response payload. error is present and None.''' |
|
||||
return {'id': id_, 'result': result, 'error': None} |
|
||||
|
|
||||
@classmethod |
|
||||
def error_payload(cls, message, code, id_): |
|
||||
'''JSON v1 error payload. result is present and None.''' |
|
||||
return {'id': id_, 'result': None, |
|
||||
'error': {'message': message, 'code': code}} |
|
||||
|
|
||||
@classmethod |
|
||||
def handle_response(cls, handler, payload): |
|
||||
'''JSON v1 response handler. Both 'error' and 'result' |
|
||||
should exist with exactly one being None. |
|
||||
|
|
||||
Unfortunately many 1.0 clients behave like 2.0, and just send |
|
||||
one or the other. |
|
||||
''' |
|
||||
error = payload.get('error') |
|
||||
if error is None: |
|
||||
handler(payload.get('result'), None) |
|
||||
else: |
|
||||
handler(None, cls.canonical_error(error)) |
|
||||
|
|
||||
@classmethod |
|
||||
def is_request(cls, payload): |
|
||||
'''Returns True if the payload (which has a method) is a request. |
|
||||
False means it is a notification.''' |
|
||||
return payload.get('id') is not None |
|
||||
|
|
||||
|
|
||||
class JSONRPCv2(JSONRPC): |
|
||||
'''JSON RPC version 2.0.''' |
|
||||
|
|
||||
HAS_BATCHES = True |
|
||||
|
|
||||
@classmethod |
|
||||
def request_payload(cls, id_, method, params=None): |
|
||||
'''JSON v2 request payload. Params is optional.''' |
|
||||
payload = {'jsonrpc': '2.0', 'method': method, 'id': id_} |
|
||||
if params: |
|
||||
payload['params'] = params |
|
||||
return payload |
|
||||
|
|
||||
@classmethod |
|
||||
def notification_payload(cls, method, params=None): |
|
||||
'''JSON v2 notification payload. There must be no id.''' |
|
||||
payload = {'jsonrpc': '2.0', 'method': method} |
|
||||
if params: |
|
||||
payload['params'] = params |
|
||||
return payload |
|
||||
|
|
||||
@classmethod |
|
||||
def response_payload(cls, result, id_): |
|
||||
'''JSON v2 response payload. error is not present.''' |
|
||||
return {'jsonrpc': '2.0', 'id': id_, 'result': result} |
|
||||
|
|
||||
@classmethod |
|
||||
def error_payload(cls, message, code, id_): |
|
||||
'''JSON v2 error payload. result is not present.''' |
|
||||
return {'jsonrpc': '2.0', 'id': id_, |
|
||||
'error': {'message': message, 'code': code}} |
|
||||
|
|
||||
@classmethod |
|
||||
def handle_response(cls, handler, payload): |
|
||||
'''JSON v2 response handler. Exactly one of 'error' and 'result' |
|
||||
must exist. Errors must have 'code' and 'message' members. |
|
||||
''' |
|
||||
if 'error' in payload: |
|
||||
handler(None, cls.canonical_error(payload['error'])) |
|
||||
elif 'result' in payload: |
|
||||
handler(payload['result'], None) |
|
||||
else: |
|
||||
error = {'message': 'no error or result returned', |
|
||||
'code': JSONRPC.INVALID_RESPONSE} |
|
||||
handler(None, cls.canonical_error(error)) |
|
||||
|
|
||||
@classmethod |
|
||||
def batch_size(cls, parts): |
|
||||
'''Return the size of a JSON batch from its parts.''' |
|
||||
return sum(len(part) for part in parts) + 2 * len(parts) |
|
||||
|
|
||||
@classmethod |
|
||||
def batch_bytes(cls, parts): |
|
||||
'''Return the bytes of a JSON batch from its parts.''' |
|
||||
if parts: |
|
||||
return b'[' + b', '.join(parts) + b']' |
|
||||
return b'' |
|
||||
|
|
||||
@classmethod |
|
||||
def is_request(cls, payload): |
|
||||
'''Returns True if the payload (which has a method) is a request. |
|
||||
False means it is a notification.''' |
|
||||
return 'id' in payload |
|
||||
|
|
||||
|
|
||||
class JSONRPCCompat(JSONRPC): |
|
||||
'''Intended to be used until receiving a response from the peer, at |
|
||||
which point detect_version should be used to choose which version |
|
||||
to use. |
|
||||
|
|
||||
Sends requests compatible with v1 and v2. Errors cannot be |
|
||||
compatible so v2 errors are sent. |
|
||||
|
|
||||
Does not send responses or notifications, nor handle responses. |
|
||||
|
|
||||
''' |
|
||||
@classmethod |
|
||||
def request_payload(cls, id_, method, params=None): |
|
||||
'''JSON v2 request payload but with params present.''' |
|
||||
return {'jsonrpc': '2.0', 'id': id_, |
|
||||
'method': method, 'params': params or []} |
|
||||
|
|
||||
@classmethod |
|
||||
def error_payload(cls, message, code, id_): |
|
||||
'''JSON v2 error payload. result is not present.''' |
|
||||
return {'jsonrpc': '2.0', 'id': id_, |
|
||||
'error': {'message': message, 'code': code}} |
|
||||
|
|
||||
@classmethod |
|
||||
def detect_version(cls, payload): |
|
||||
'''Return a best guess at a version compatible with the received |
|
||||
payload. |
|
||||
|
|
||||
Return None if one cannot be determined. |
|
||||
''' |
|
||||
def item_version(item): |
|
||||
if isinstance(item, dict): |
|
||||
version = item.get('jsonrpc') |
|
||||
if version is None: |
|
||||
return JSONRPCv1 |
|
||||
if version == '2.0': |
|
||||
return JSONRPCv2 |
|
||||
return None |
|
||||
|
|
||||
if isinstance(payload, list) and payload: |
|
||||
version = item_version(payload[0]) |
|
||||
# If a batch return at least JSONRPCv2 |
|
||||
if version in (JSONRPCv1, None): |
|
||||
version = JSONRPCv2 |
|
||||
else: |
|
||||
version = item_version(payload) |
|
||||
|
|
||||
return version |
|
||||
|
|
||||
|
|
||||
class JSONSessionBase(util.LoggedClass): |
|
||||
'''Acts as the application layer session, communicating via JSON RPC |
|
||||
over an underlying transport. |
|
||||
|
|
||||
Processes incoming and sends outgoing requests, notifications and |
|
||||
responses. Incoming messages are queued. When the queue goes |
|
||||
from empty |
|
||||
''' |
|
||||
_next_session_id = 0 |
|
||||
_pending_reqs = {} # Outgoing requests waiting for a response |
|
||||
|
|
||||
@classmethod |
|
||||
def next_session_id(cls): |
|
||||
'''Return the next unique session ID.''' |
|
||||
session_id = cls._next_session_id |
|
||||
cls._next_session_id += 1 |
|
||||
return session_id |
|
||||
|
|
||||
def _pending_request_keys(self): |
|
||||
'''Return a generator of pending request keys for this session.''' |
|
||||
return [key for key in self._pending_reqs if key[0] is self] |
|
||||
|
|
||||
def has_pending_requests(self): |
|
||||
'''Return True if this session has pending requests.''' |
|
||||
return bool(self._pending_request_keys()) |
|
||||
|
|
||||
def pop_response_handler(self, msg_id): |
|
||||
'''Return the response handler for the given message ID.''' |
|
||||
return self._pending_reqs.pop((self, msg_id), (None, None))[0] |
|
||||
|
|
||||
def timeout_session(self): |
|
||||
'''Trigger timeouts for all of the session's pending requests.''' |
|
||||
self._timeout_requests(self._pending_request_keys()) |
|
||||
|
|
||||
@classmethod |
|
||||
def timeout_check(cls): |
|
||||
'''Trigger timeouts where necessary for all pending requests.''' |
|
||||
now = time.time() |
|
||||
keys = [key for key, value in cls._pending_reqs.items() |
|
||||
if value[1] < now] |
|
||||
cls._timeout_requests(keys) |
|
||||
|
|
||||
@classmethod |
|
||||
def _timeout_requests(cls, keys): |
|
||||
'''Trigger timeouts for the given lookup keys.''' |
|
||||
values = [cls._pending_reqs.pop(key) for key in keys] |
|
||||
handlers = [handler for handler, timeout in values] |
|
||||
timeout_error = JSONRPC.timeout_error() |
|
||||
for handler in handlers: |
|
||||
handler(None, timeout_error) |
|
||||
|
|
||||
def __init__(self, version=JSONRPCCompat): |
|
||||
super().__init__() |
|
||||
|
|
||||
# Parts of an incomplete JSON line. We buffer them until |
|
||||
# getting a newline. |
|
||||
self.parts = [] |
|
||||
self.version = version |
|
||||
self.log_me = False |
|
||||
self.session_id = None |
|
||||
# Count of incoming complete JSON requests and the time of the |
|
||||
# last one. A batch counts as just one here. |
|
||||
self.last_recv = time.time() |
|
||||
self.send_count = 0 |
|
||||
self.send_size = 0 |
|
||||
self.recv_size = 0 |
|
||||
self.recv_count = 0 |
|
||||
self.error_count = 0 |
|
||||
self.pause = False |
|
||||
# Handling of incoming items |
|
||||
self.items = collections.deque() |
|
||||
self.items_event = asyncio.Event() |
|
||||
self.batch_results = [] |
|
||||
# Handling of outgoing requests |
|
||||
self.next_request_id = 0 |
|
||||
# If buffered incoming data exceeds this the connection is closed |
|
||||
self.max_buffer_size = 1000000 |
|
||||
self.max_send = 50000 |
|
||||
self.close_after_send = False |
|
||||
|
|
||||
def pause_writing(self): |
|
||||
'''Transport calls when the send buffer is full.''' |
|
||||
self.log_info('pausing processing whilst socket drains') |
|
||||
self.pause = True |
|
||||
|
|
||||
def resume_writing(self): |
|
||||
'''Transport calls when the send buffer has room.''' |
|
||||
self.log_info('resuming processing') |
|
||||
self.pause = False |
|
||||
|
|
||||
def is_oversized(self, length, id_): |
|
||||
'''Return an error payload if the given outgoing message size is too |
|
||||
large, or False if not. |
|
||||
''' |
|
||||
if self.max_send and length > max(1000, self.max_send): |
|
||||
msg = 'response too large (at least {:d} bytes)'.format(length) |
|
||||
return self.error_bytes(msg, JSONRPC.INVALID_REQUEST, id_) |
|
||||
return False |
|
||||
|
|
||||
def send_binary(self, binary): |
|
||||
'''Pass the bytes through to the transport. |
|
||||
|
|
||||
Close the connection if close_after_send is set. |
|
||||
''' |
|
||||
if self.is_closing(): |
|
||||
return |
|
||||
self.using_bandwidth(len(binary)) |
|
||||
self.send_count += 1 |
|
||||
self.send_size += len(binary) |
|
||||
self.send_bytes(binary) |
|
||||
if self.close_after_send: |
|
||||
self.close_connection() |
|
||||
|
|
||||
def payload_id(self, payload): |
|
||||
'''Extract and return the ID from the payload. |
|
||||
|
|
||||
Returns None if it is missing or invalid.''' |
|
||||
try: |
|
||||
return self.check_payload_id(payload) |
|
||||
except RPCError: |
|
||||
return None |
|
||||
|
|
||||
def check_payload_id(self, payload): |
|
||||
'''Extract and return the ID from the payload. |
|
||||
|
|
||||
Raises an RPCError if it is missing or invalid.''' |
|
||||
if 'id' not in payload: |
|
||||
raise RPCError('missing id', JSONRPC.INVALID_REQUEST) |
|
||||
|
|
||||
id_ = payload['id'] |
|
||||
if not isinstance(id_, self.version.ID_TYPES): |
|
||||
raise RPCError('invalid id type {}'.format(type(id_)), |
|
||||
JSONRPC.INVALID_REQUEST) |
|
||||
return id_ |
|
||||
|
|
||||
def request_bytes(self, id_, method, params=None): |
|
||||
'''Return the bytes of a JSON request.''' |
|
||||
payload = self.version.request_payload(id_, method, params) |
|
||||
return self.encode_payload(payload) |
|
||||
|
|
||||
def notification_bytes(self, method, params=None): |
|
||||
payload = self.version.notification_payload(method, params) |
|
||||
return self.encode_payload(payload) |
|
||||
|
|
||||
def response_bytes(self, result, id_): |
|
||||
'''Return the bytes of a JSON response.''' |
|
||||
return self.encode_payload(self.version.response_payload(result, id_)) |
|
||||
|
|
||||
def error_bytes(self, message, code, id_=None): |
|
||||
'''Return the bytes of a JSON error. |
|
||||
|
|
||||
Flag the connection to close on a fatal error or too many errors.''' |
|
||||
version = self.version |
|
||||
self.error_count += 1 |
|
||||
if not self.close_after_send: |
|
||||
fatal_log = None |
|
||||
if code in (version.PARSE_ERROR, version.INVALID_REQUEST, |
|
||||
version.FATAL_ERROR): |
|
||||
fatal_log = message |
|
||||
elif self.error_count >= 10: |
|
||||
fatal_log = 'too many errors, last: {}'.format(message) |
|
||||
if fatal_log: |
|
||||
self.log_info(fatal_log) |
|
||||
self.close_after_send = True |
|
||||
return self.encode_payload(self.version.error_payload |
|
||||
(message, code, id_)) |
|
||||
|
|
||||
def encode_payload(self, payload): |
|
||||
'''Encode a Python object as binary bytes.''' |
|
||||
assert isinstance(payload, dict) |
|
||||
|
|
||||
id_ = payload.get('id') |
|
||||
try: |
|
||||
binary = json.dumps(payload).encode() |
|
||||
except TypeError: |
|
||||
msg = 'JSON encoding failure: {}'.format(payload) |
|
||||
self.log_error(msg) |
|
||||
binary = self.error_bytes(msg, JSONRPC.INTERNAL_ERROR, id_) |
|
||||
|
|
||||
error_bytes = self.is_oversized(len(binary), id_) |
|
||||
return error_bytes or binary |
|
||||
|
|
||||
def decode_message(self, payload): |
|
||||
'''Decode a binary message and pass it on to process_single_item or |
|
||||
process_batch as appropriate. |
|
||||
|
|
||||
Messages that cannot be decoded are logged and dropped. |
|
||||
''' |
|
||||
try: |
|
||||
payload = payload.decode() |
|
||||
except UnicodeDecodeError as e: |
|
||||
msg = 'cannot decode message: {}'.format(e) |
|
||||
self.send_error(msg, JSONRPC.PARSE_ERROR) |
|
||||
return |
|
||||
|
|
||||
try: |
|
||||
payload = json.loads(payload) |
|
||||
except json.JSONDecodeError as e: |
|
||||
msg = 'cannot decode JSON: {}'.format(e) |
|
||||
self.send_error(msg, JSONRPC.PARSE_ERROR) |
|
||||
return |
|
||||
|
|
||||
if self.version is JSONRPCCompat: |
|
||||
# Attempt to detect peer's JSON RPC version |
|
||||
version = self.version.detect_version(payload) |
|
||||
if not version: |
|
||||
version = JSONRPCv2 |
|
||||
self.log_info('unable to detect JSON RPC version, using 2.0') |
|
||||
self.version = version |
|
||||
|
|
||||
# Batches must have at least one object. |
|
||||
if payload == [] and self.version.HAS_BATCHES: |
|
||||
self.send_error('empty batch', JSONRPC.INVALID_REQUEST) |
|
||||
return |
|
||||
|
|
||||
self.items.append(payload) |
|
||||
self.items_event.set() |
|
||||
|
|
||||
async def process_batch(self, batch, count): |
|
||||
'''Processes count items from the batch according to the JSON 2.0 |
|
||||
spec. |
|
||||
|
|
||||
If any remain, puts what is left of the batch back in the deque |
|
||||
and returns None. Otherwise returns the binary batch result.''' |
|
||||
results = self.batch_results |
|
||||
self.batch_results = [] |
|
||||
|
|
||||
for n in range(count): |
|
||||
item = batch.pop() |
|
||||
result = await self.process_single_item(item) |
|
||||
if result: |
|
||||
results.append(result) |
|
||||
|
|
||||
if not batch: |
|
||||
return self.version.batch_bytes(results) |
|
||||
|
|
||||
error_bytes = self.is_oversized(self.batch_size(results), None) |
|
||||
if error_bytes: |
|
||||
return error_bytes |
|
||||
|
|
||||
self.items.appendleft(item) |
|
||||
self.batch_results = results |
|
||||
return None |
|
||||
|
|
||||
async def process_single_item(self, payload): |
|
||||
'''Handle a single JSON request, notification or response. |
|
||||
|
|
||||
If it is a request, return the binary response, oterhwise None.''' |
|
||||
if self.log_me: |
|
||||
self.log_info('processing {}'.format(payload)) |
|
||||
|
|
||||
if not isinstance(payload, dict): |
|
||||
return self.error_bytes('request must be a dictionary', |
|
||||
JSONRPC.INVALID_REQUEST) |
|
||||
|
|
||||
try: |
|
||||
# Requests and notifications must have a method. |
|
||||
if 'method' in payload: |
|
||||
if self.version.is_request(payload): |
|
||||
return await self.process_single_request(payload) |
|
||||
else: |
|
||||
await self.process_single_notification(payload) |
|
||||
else: |
|
||||
self.process_single_response(payload) |
|
||||
|
|
||||
return None |
|
||||
except asyncio.CancelledError: |
|
||||
raise |
|
||||
except Exception: |
|
||||
self.log_error(traceback.format_exc()) |
|
||||
return self.error_bytes('internal error processing request', |
|
||||
JSONRPC.INTERNAL_ERROR, |
|
||||
self.payload_id(payload)) |
|
||||
|
|
||||
async def process_single_request(self, payload): |
|
||||
'''Handle a single JSON request and return the binary response.''' |
|
||||
try: |
|
||||
result = await self.handle_payload(payload, self.request_handler) |
|
||||
return self.response_bytes(result, payload['id']) |
|
||||
except RPCError as e: |
|
||||
return self.error_bytes(e.msg, e.code, self.payload_id(payload)) |
|
||||
except asyncio.CancelledError: |
|
||||
raise |
|
||||
except Exception: |
|
||||
self.log_error(traceback.format_exc()) |
|
||||
return self.error_bytes('internal error processing request', |
|
||||
JSONRPC.INTERNAL_ERROR, |
|
||||
self.payload_id(payload)) |
|
||||
|
|
||||
async def process_single_notification(self, payload): |
|
||||
'''Handle a single JSON notification.''' |
|
||||
try: |
|
||||
await self.handle_payload(payload, self.notification_handler) |
|
||||
except RPCError: |
|
||||
pass |
|
||||
except Exception: |
|
||||
self.log_error(traceback.format_exc()) |
|
||||
|
|
||||
def process_single_response(self, payload): |
|
||||
'''Handle a single JSON response.''' |
|
||||
try: |
|
||||
id_ = self.check_payload_id(payload) |
|
||||
handler = self.pop_response_handler(id_) |
|
||||
if handler: |
|
||||
self.version.handle_response(handler, payload) |
|
||||
else: |
|
||||
self.log_info('response for unsent id {}'.format(id_), |
|
||||
throttle=True) |
|
||||
except RPCError: |
|
||||
pass |
|
||||
except Exception: |
|
||||
self.log_error(traceback.format_exc()) |
|
||||
|
|
||||
async def handle_payload(self, payload, get_handler): |
|
||||
'''Handle a request or notification payload given the handlers.''' |
|
||||
# An argument is the value passed to a function parameter... |
|
||||
args = payload.get('params', []) |
|
||||
method = payload.get('method') |
|
||||
|
|
||||
if not isinstance(method, str): |
|
||||
raise RPCError("invalid method type {}".format(type(method)), |
|
||||
JSONRPC.INVALID_REQUEST) |
|
||||
|
|
||||
handler = get_handler(method) |
|
||||
if not handler: |
|
||||
raise RPCError("unknown method: '{}'".format(method), |
|
||||
JSONRPC.METHOD_NOT_FOUND) |
|
||||
|
|
||||
if not isinstance(args, (list, dict)): |
|
||||
raise RPCError('arguments should be an array or dictionary', |
|
||||
JSONRPC.INVALID_REQUEST) |
|
||||
|
|
||||
params = inspect.signature(handler).parameters |
|
||||
names = list(params) |
|
||||
min_args = sum(p.default is p.empty for p in params.values()) |
|
||||
|
|
||||
if len(args) < min_args: |
|
||||
raise RPCError('too few arguments to {}: expected {:d} got {:d}' |
|
||||
.format(method, min_args, len(args)), |
|
||||
JSONRPC.INVALID_ARGS) |
|
||||
|
|
||||
if len(args) > len(params): |
|
||||
raise RPCError('too many arguments to {}: expected {:d} got {:d}' |
|
||||
.format(method, len(params), len(args)), |
|
||||
JSONRPC.INVALID_ARGS) |
|
||||
|
|
||||
if isinstance(args, list): |
|
||||
kw_args = {name: arg for name, arg in zip(names, args)} |
|
||||
else: |
|
||||
kw_args = args |
|
||||
bad_names = ['<{}>'.format(name) for name in args |
|
||||
if name not in names] |
|
||||
if bad_names: |
|
||||
raise RPCError('invalid parameter names: {}' |
|
||||
.format(', '.join(bad_names))) |
|
||||
|
|
||||
if inspect.iscoroutinefunction(handler): |
|
||||
return await handler(**kw_args) |
|
||||
else: |
|
||||
return handler(**kw_args) |
|
||||
|
|
||||
# ---- External Interface ---- |
|
||||
|
|
||||
async def process_pending_items(self, limit=8): |
|
||||
'''Processes up to LIMIT pending items asynchronously.''' |
|
||||
while limit > 0 and self.items: |
|
||||
item = self.items.popleft() |
|
||||
if isinstance(item, list) and self.version.HAS_BATCHES: |
|
||||
count = min(limit, len(item)) |
|
||||
binary = await self.process_batch(item, count) |
|
||||
limit -= count |
|
||||
else: |
|
||||
binary = await self.process_single_item(item) |
|
||||
limit -= 1 |
|
||||
|
|
||||
if binary: |
|
||||
self.send_binary(binary) |
|
||||
|
|
||||
if not self.items: |
|
||||
self.items_event.clear() |
|
||||
|
|
||||
def count_pending_items(self): |
|
||||
'''Counts the number of pending items.''' |
|
||||
return sum(len(item) if isinstance(item, list) else 1 |
|
||||
for item in self.items) |
|
||||
|
|
||||
def connection_made(self): |
|
||||
'''Call when an incoming client connection is established.''' |
|
||||
self.session_id = self.next_session_id() |
|
||||
self.log_prefix = '[{:d}] '.format(self.session_id) |
|
||||
|
|
||||
def data_received(self, data): |
|
||||
'''Underlying transport calls this when new data comes in. |
|
||||
|
|
||||
Look for newline separators terminating full requests. |
|
||||
''' |
|
||||
if self.is_closing(): |
|
||||
return |
|
||||
self.using_bandwidth(len(data)) |
|
||||
self.recv_size += len(data) |
|
||||
|
|
||||
# Close abusive connections where buffered data exceeds limit |
|
||||
buffer_size = len(data) + sum(len(part) for part in self.parts) |
|
||||
if buffer_size > self.max_buffer_size: |
|
||||
self.log_error('read buffer of {:,d} bytes over {:,d} byte limit' |
|
||||
.format(buffer_size, self.max_buffer_size)) |
|
||||
self.close_connection() |
|
||||
return |
|
||||
|
|
||||
while True: |
|
||||
npos = data.find(ord('\n')) |
|
||||
if npos == -1: |
|
||||
self.parts.append(data) |
|
||||
break |
|
||||
tail, data = data[:npos], data[npos + 1:] |
|
||||
parts, self.parts = self.parts, [] |
|
||||
parts.append(tail) |
|
||||
self.recv_count += 1 |
|
||||
self.last_recv = time.time() |
|
||||
self.decode_message(b''.join(parts)) |
|
||||
|
|
||||
def send_error(self, message, code, id_=None): |
|
||||
'''Send a JSON error.''' |
|
||||
self.send_binary(self.error_bytes(message, code, id_)) |
|
||||
|
|
||||
def send_request(self, handler, method, params=None, timeout=30): |
|
||||
'''Sends a request and arranges for handler to be called with the |
|
||||
response when it comes in. |
|
||||
|
|
||||
A call to request_timeout_check() will result in pending |
|
||||
responses that have been waiting more than timeout seconds to |
|
||||
call the handler with a REQUEST_TIMEOUT error. |
|
||||
''' |
|
||||
id_ = self.next_request_id |
|
||||
self.next_request_id += 1 |
|
||||
self.send_binary(self.request_bytes(id_, method, params)) |
|
||||
self._pending_reqs[(self, id_)] = (handler, time.time() + timeout) |
|
||||
|
|
||||
def send_notification(self, method, params=None): |
|
||||
'''Send a notification.''' |
|
||||
self.send_binary(self.notification_bytes(method, params)) |
|
||||
|
|
||||
def send_notifications(self, mp_iterable): |
|
||||
'''Send an iterable of (method, params) notification pairs. |
|
||||
|
|
||||
A 1-tuple is also valid in which case there are no params.''' |
|
||||
if False and self.version.HAS_BATCHES: |
|
||||
parts = [self.notification_bytes(*pair) for pair in mp_iterable] |
|
||||
self.send_binary(self.version.batch_bytes(parts)) |
|
||||
else: |
|
||||
for pair in mp_iterable: |
|
||||
self.send_notification(*pair) |
|
||||
|
|
||||
# -- derived classes are intended to override these functions |
|
||||
|
|
||||
# Transport layer |
|
||||
|
|
||||
def is_closing(self): |
|
||||
'''Return True if the underlying transport is closing.''' |
|
||||
raise NotImplementedError |
|
||||
|
|
||||
def close_connection(self): |
|
||||
'''Close the connection.''' |
|
||||
raise NotImplementedError |
|
||||
|
|
||||
def send_bytes(self, binary): |
|
||||
'''Pass the bytes through to the underlying transport.''' |
|
||||
raise NotImplementedError |
|
||||
|
|
||||
# App layer |
|
||||
|
|
||||
def using_bandwidth(self, amount): |
|
||||
'''Called as bandwidth is consumed. |
|
||||
|
|
||||
Override to implement bandwidth management. |
|
||||
''' |
|
||||
pass |
|
||||
|
|
||||
def notification_handler(self, method): |
|
||||
'''Return the handler for the given notification. |
|
||||
|
|
||||
The handler can be synchronous or asynchronous.''' |
|
||||
return None |
|
||||
|
|
||||
def request_handler(self, method): |
|
||||
'''Return the handler for the given request method. |
|
||||
|
|
||||
The handler can be synchronous or asynchronous.''' |
|
||||
return None |
|
||||
|
|
||||
|
|
||||
class JSONSession(JSONSessionBase, asyncio.Protocol): |
|
||||
'''A JSONSessionBase instance specialized for use with |
|
||||
asyncio.protocol to implement the transport layer. |
|
||||
|
|
||||
The app should await on items_event, which is set when unprocessed |
|
||||
incoming items remain and cleared when the queue is empty, and |
|
||||
then arrange to call process_pending_items asynchronously. |
|
||||
|
|
||||
Derived classes may want to override the request and notification |
|
||||
handlers. |
|
||||
''' |
|
||||
|
|
||||
def __init__(self, version=JSONRPCCompat): |
|
||||
super().__init__(version=version) |
|
||||
self.transport = None |
|
||||
self.write_buffer_high = 500000 |
|
||||
|
|
||||
def peer_info(self): |
|
||||
'''Returns information about the peer.''' |
|
||||
try: |
|
||||
# get_extra_info can throw even if self.transport is not None |
|
||||
return self.transport.get_extra_info('peername') |
|
||||
except Exception: |
|
||||
return None |
|
||||
|
|
||||
def abort(self): |
|
||||
'''Cut the connection abruptly.''' |
|
||||
self.transport.abort() |
|
||||
|
|
||||
def connection_made(self, transport): |
|
||||
'''Handle an incoming client connection.''' |
|
||||
transport.set_write_buffer_limits(high=self.write_buffer_high) |
|
||||
self.transport = transport |
|
||||
super().connection_made() |
|
||||
|
|
||||
def connection_lost(self, exc): |
|
||||
'''Trigger timeouts of all pending requests.''' |
|
||||
self.timeout_session() |
|
||||
|
|
||||
def is_closing(self): |
|
||||
'''True if the underlying transport is closing.''' |
|
||||
return self.transport and self.transport.is_closing() |
|
||||
|
|
||||
def close_connection(self): |
|
||||
'''Close the connection.''' |
|
||||
if self.transport: |
|
||||
self.transport.close() |
|
||||
|
|
||||
def send_bytes(self, binary): |
|
||||
'''Send JSON text over the transport.''' |
|
||||
self.transport.writelines((binary, b'\n')) |
|
||||
|
|
||||
def peer_addr(self, anon=True): |
|
||||
'''Return the peer address and port.''' |
|
||||
peer_info = self.peer_info() |
|
||||
if not peer_info: |
|
||||
return 'unknown' |
|
||||
if anon: |
|
||||
return 'xx.xx.xx.xx:xx' |
|
||||
if ':' in peer_info[0]: |
|
||||
return '[{}]:{}'.format(peer_info[0], peer_info[1]) |
|
||||
else: |
|
||||
return '{}:{}'.format(peer_info[0], peer_info[1]) |
|
@ -1,5 +1,5 @@ |
|||||
# Server name and protocol versions |
# Server name and protocol versions |
||||
|
|
||||
VERSION = 'ElectrumX 1.3.1' |
VERSION = 'ElectrumX 1.3.1a' |
||||
PROTOCOL_MIN = '0.9' |
PROTOCOL_MIN = '0.9' |
||||
PROTOCOL_MAX = '1.2' |
PROTOCOL_MAX = '1.2' |
||||
|
Loading…
Reference in new issue