from typing import Union, Optional, Any, Iterable, Mapping, List, Dict from numbers import Number import warnings import collections.abc class JSONRPC20RequestIdWarning(UserWarning): pass class JSONRPC20ResponseIdWarning(UserWarning): pass class JSONRPC20Request: """JSON-RPC 2.0 Request object. A rpc call is represented by sending a Request object to a Server. The Request object has the following members: jsonrpc A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". method A String containing the name of the method to be invoked. Method names that begin with the word rpc followed by a period character (U+002E or ASCII 46) are reserved for rpc-internal methods and extensions and MUST NOT be used for anything else. params A Structured value that holds the parameter values to be used during the invocation of the method. This member MAY be omitted. id An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]. The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. Notification A Notification is a Request object without an "id" member. A Request object that is a Notification signifies the Client's lack of interest in the corresponding Response object, and as such no Response object needs to be returned to the client. The Server MUST NOT reply to a Notification, including those that are within a batch request. Notifications are not confirmable by definition, since they do not have a Response object to be returned. As such, the Client would not be aware of any errors (like e.g. "Invalid params","Internal error"). Parameter Structures If present, parameters for the rpc call MUST be provided as a Structured value. Either by-position through an Array or by-name through an Object. by-position: params MUST be an Array, containing the values in the Server expected order. by-name: params MUST be an Object, with member names that match the Server expected parameter names. The absence of expected names MAY result in an error being generated. The names MUST match exactly, including case, to the method's expected parameters. Note: Design principles: * if an object is created or modified without exceptions, its state is valid. * There is a signle source of truth (see Attributes), modification should be done via setters. Args: method (str): method to call. params (:obj:dict, optional): a mapping of method argument values or a list of positional arguments. id: an id of the request. By default equals to None and raises warning. For notifications set is_notification=True so id would not be included in the body. is_notification: a boolean flag indicating whether to include id in the body or not. Attributes: _body (dict): body of the request. It should always contain valid data and should be modified via setters to ensure validity. Examples: modification via self.body["method"] vs self.method modifications via self._body """ def __init__(self, method: str, params: Optional[Union[Mapping[str, Any], Iterable[Any]]] = None, id: Optional[Union[str, int]] = None, is_notification: bool = False ) -> None: request_body = { "jsonrpc": "2.0", "method": method, } if params is not None: # If params are not present, they should not be in body request_body["params"] = params if not is_notification: # For non-notifications "id" has to be in body, even if null request_body["id"] = id self._body = {} # init body self.body = request_body @property def body(self): return self._body @body.setter def body(self, value: Mapping[str, Any]) -> None: if not isinstance(value, Mapping): raise ValueError("request body has to be of type Mapping") extra_keys = set(value.keys()) - {"jsonrpc", "method", "params", "id"} if len(extra_keys) > 0: raise ValueError("unexpected keys {}".format(extra_keys)) if value.get("jsonrpc") != "2.0": raise ValueError("value of key 'jsonrpc' has to be '2.0'") self.validate_method(value.get("method")) if "params" in value: self.validate_params(value["params"]) # Validate id for non-notification if "id" in value: self.validate_id(value["id"]) self._body = value @property def method(self) -> str: return self.body["method"] @staticmethod def validate_method(value: str) -> None: if not isinstance(value, str): raise ValueError("Method should be string") if value.startswith("rpc."): raise ValueError( "Method names that begin with the word rpc followed by a " + "period character (U+002E or ASCII 46) are reserved for " + "rpc-internal methods and extensions and MUST NOT be used " + "for anything else.") @method.setter def method(self, value: str) -> None: self.validate_method(value) self._body["method"] = value @property def params(self) -> Optional[Union[Mapping[str, Any], Iterable[Any]]]: return self.body.get("params") @staticmethod def validate_params(value: Optional[Union[Mapping[str, Any], Iterable[Any]]]) -> None: """ Note: params has to be None, dict or iterable. In the latter case it would be converted to a list. It is possible to set param as tuple or even string as they are iterables, they would be converted to lists, e.g. ["h", "e", "l", "l", "o"] """ if not isinstance(value, (Mapping, Iterable)): raise ValueError("Incorrect params {0}".format(value)) @params.setter def params(self, value: Optional[Union[Mapping[str, Any], Iterable[Any]]]) -> None: self.validate_params(value) self._body["params"] = value @params.deleter def params(self): del self._body["params"] @property def id(self): return self.body["id"] @staticmethod def validate_id(value: Optional[Union[str, Number]]) -> None: if value is None: warnings.warn( "The use of Null as a value for the id member in a Request " "object is discouraged, because this specification uses a " "value of Null for Responses with an unknown id. Also, because" " JSON-RPC 1.0 uses an id value of Null for Notifications this" " could cause confusion in handling.", JSONRPC20RequestIdWarning ) return if not isinstance(value, (str, Number)): raise ValueError("id MUST contain a String, Number, or NULL value") if isinstance(value, Number) and not isinstance(value, int): warnings.warn( "Fractional parts may be problematic, since many decimal " "fractions cannot be represented exactly as binary fractions.", JSONRPC20RequestIdWarning ) @id.setter def id(self, value: Optional[Union[str, Number]]) -> None: self.validate_id(value) self._body["id"] = value @id.deleter def id(self): del self._body["id"] @property def is_notification(self): """Check if request is a notification. There is no API to make a request notification as this has to remove "id" from body and might cause confusion. To make a request notification delete "id" explicitly. """ return "id" not in self.body @property def args(self) -> List: """ Method position arguments. :return list args: method position arguments. note: dict is also iterable, so exclude it from args. """ # if not none and not mapping return list(self.params) if isinstance(self.params, Iterable) and not isinstance(self.params, Mapping) else [] @property def kwargs(self) -> Dict: """ Method named arguments. :return dict kwargs: method named arguments. """ # if mapping return dict(self.params) if isinstance(self.params, Mapping) else {} @staticmethod def from_body(body: Mapping): request = JSONRPC20Request(method="", id=0) request.body = body return request class JSONRPC20BatchRequest(collections.abc.MutableSequence): def __init__(self, requests: List[JSONRPC20Request] = None): self.requests = requests or [] def __getitem__(self, index): return self.requests[index] def __setitem__(self, index, value: JSONRPC20Request): self.requests[index] = value def __delitem__(self, index): del self.requests[index] def __len__(self): return len(self.requests) def insert(self, index, value: JSONRPC20Request): self.requests.insert(index, value) @property def body(self): return [request.body for request in self] class JSONRPC20Error: """Error object. When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a Object with the following members: code A Number that indicates the error type that occurred. This MUST be an integer. message A String providing a short description of the error. The message SHOULD be limited to a concise single sentence. data A Primitive or Structured value that contains additional information about the error. This may be omitted. The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.). The error codes from and including -32768 to -32000 are reserved for pre-defined errors. Any code within this range, but not defined explicitly below is reserved for future use. The error codes are nearly the same as those suggested for XML-RPC at the following url: http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php code | message | meaning -----------------|------------------|------------------------------------- -32700 | Parse error | Invalid JSON was received by the server. | An error occurred on the server while parsing the JSON text. -32600 | Invalid Request | The JSON sent is not a valid Request object. -32601 | Method not found | The method does not exist / is not available. -32602 | Invalid params | Invalid method parameter(s). -32603 | Internal error | Internal JSON-RPC error. -32000 to -32099 | Server error | Reserved for implementation-defined server-errors. The remainder of the space is available for application defined errors. """ def __init__(self, code: int, message: str, data: Any = None): error_body = { "code": code, "message": message, } if data is not None: # NOTE: if not set in constructor, do not add 'data' to payload. # If data = null is requred, set it after object initialization. error_body["data"] = data self._body = {} # init body self.body = error_body def __eq__(self, other): return self.code == other.code \ and self.message == other.message \ and self.data == other.data @property def body(self): return self._body @body.setter def body(self, value): if not isinstance(value, dict): raise ValueError("value has to be of type dict") self.validate_code(value["code"]) self.validate_message(value["message"]) self._body = value @property def code(self): return self.body["code"] @staticmethod def validate_code(value: int) -> None: if not isinstance(value, int): raise ValueError("Error code MUST be an integer") @code.setter def code(self, value: int) -> None: self.validate_code(value) self._body["code"] = value @property def message(self) -> str: return self.body["message"] @staticmethod def validate_message(value: str) -> None: if not isinstance(value, str): raise ValueError("Error message should be string") @message.setter def message(self, value: str): self.validate_message(value) self._body["message"] = value @property def data(self): return self._body.get("data") @data.setter def data(self, value): self._body["data"] = value @data.deleter def data(self): del self._body["data"] @staticmethod def validate_body(value: dict) -> None: if not (set(value.keys()) <= {"code", "message", "data"}): raise ValueError("Error body could have only 'code', 'message' and 'data' keys") JSONRPC20Error.validate_code(value.get("code")) JSONRPC20Error.validate_message(value.get("message")) class JSONRPC20SpecificError(JSONRPC20Error): """Base class for errors with fixed code and message. Keep only data in constructor and forbid code and message modifications. """ def __init__(self, data: Any = None): super(JSONRPC20SpecificError, self).__init__(getattr(self.__class__, "CODE"), getattr(self.__class__, "MESSAGE"), data) def __setattr__(self, attr, value): if attr == "code": raise NotImplementedError("Code modification is forbidden") elif attr == "message": raise NotImplementedError("Message modification is forbidden") else: super(JSONRPC20SpecificError, self).__setattr__(attr, value) class JSONRPC20ParseError(JSONRPC20SpecificError): """Parse Error. Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. """ CODE = -32700 MESSAGE = "Parse error" class JSONRPC20InvalidRequest(JSONRPC20SpecificError): """Invalid Request. The JSON sent is not a valid Request object. """ CODE = -32600 MESSAGE = "Invalid Request" class JSONRPC20MethodNotFound(JSONRPC20SpecificError): """Method not found. The method does not exist / is not available. """ CODE = -32601 MESSAGE = "Method not found" class JSONRPC20InvalidParams(JSONRPC20SpecificError): """Invalid params. Invalid method parameter(s). """ CODE = -32602 MESSAGE = "Invalid params" class JSONRPC20InternalError(JSONRPC20SpecificError): """Internal error. Internal JSON-RPC error. """ CODE = -32603 MESSAGE = "Internal error" class JSONRPC20ServerError(JSONRPC20SpecificError): """Server error. Reserved for implementation-defined server-errors. """ CODE = -32000 MESSAGE = "Server error" class JSONRPC20Response: def __init__(self, result: Optional[Any] = None, error: Optional[JSONRPC20Error] = None, id: Optional[Union[str, int]] = None, ) -> None: response_body = { "jsonrpc": "2.0", "id": id, } if result is not None: response_body["result"] = result if error is not None: response_body["error"] = error.body self._body = {} # init body self.body = response_body @property def body(self): return self._body @body.setter def body(self, value: Mapping[str, Any]) -> None: if not isinstance(value, dict): raise ValueError("value has to be of type dict") if value.get("jsonrpc") != "2.0": raise ValueError("value['jsonrpc'] has to be '2.0'") if "result" not in value and "error" not in value: raise ValueError("Either result or error should exist") if "result" in value and "error" in value: raise ValueError("Only one result or error should exist") if "error" in value: self.validate_error(value["error"]) self.validate_id(value["id"]) self._body = { k: v for (k, v) in value.items() if k in ["jsonrpc", "result", "error", "id"] } @property def result(self) -> Optional[Any]: return self.body.get("result") @property def error(self) -> Optional[JSONRPC20Error]: if "error" in self.body: return JSONRPC20Error(**self.body["error"]) @staticmethod def validate_error(error_body: dict) -> None: JSONRPC20Error.validate_body(error_body) @property def id(self): return self.body["id"] @staticmethod def validate_id(value: Optional[Union[str, Number]]) -> None: if value is None: return if not isinstance(value, (str, Number)): raise ValueError("id MUST contain a String, Number, or NULL value") if isinstance(value, Number) and not isinstance(value, int): warnings.warn( "Fractional parts may be problematic, since many decimal " "fractions cannot be represented exactly as binary fractions.", JSONRPC20ResponseIdWarning ) @id.setter def id(self, value: Optional[Union[str, Number]]) -> None: self.validate_id(value) self._body["id"] = value class JSONRPC20BatchResponse(collections.abc.MutableSequence): def __init__(self, requests: List[JSONRPC20Response] = None): self.requests = requests or [] def __getitem__(self, index): return self.requests[index] def __setitem__(self, index, value: JSONRPC20Response): self.requests[index] = value def __delitem__(self, index): del self.requests[index] def __len__(self): return len(self.requests) def insert(self, index, value: JSONRPC20Response): self.requests.insert(index, value) @property def body(self): return [request.body for request in self] class JSONRPC20Exception(Exception): """JSON-RPC Exception class.""" pass class JSONRPC20DispatchException(JSONRPC20Exception): """JSON-RPC Base Exception for dispatcher methods.""" def __init__(self, code=None, message=None, data=None, *args, **kwargs): super(JSONRPC20DispatchException, self).__init__(args, kwargs) self.error = JSONRPC20Error(code=code, data=data, message=message)