-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
epic(tmrpc): all passing tests for v0.21 (#243)
## Migration complete! (needs cleanup refactor) - epic(tmrpc,tx): All passing tests for v0.21! - feat(jsonrpc): impement JSON-RPC 2.0 as a subpackage of nibiru - chore(deps): add type stubs for the requests and urllib pkgs - Closes #228 ## `jsonrpc` The `nibiru.jsonrpc` package implements the official [JSON-RPC 2.0 spec](https://www.jsonrpc.org/specification) in Python with strict strong typing. All of the examples written in the spec are used as test cases. A few real payloads from the chain are also mixed into the test suites. ## `tmrpc` The `nibiru.tmrpc` package implements classes for building valid queries corresponding to the Tendermint v0.37 RPC API. These types are JSON-RPC-compatible and inherit directly from the types in `nibiru.jsonrpc`. - feat(tm_rpc): implement jsonrpc version of broadcast tx with tests --- ![wk28-n1719-TabTip_DIV3](https://github.com/NibiruChain/py-sdk/assets/51418232/b976645e-00d3-4407-8a0d-109496d18c38) --- ## Related - #241
- Loading branch information
Showing
21 changed files
with
876 additions
and
87 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from nibiru.jsonrpc.jsonrpc import JsonRPCID # noqa | ||
from nibiru.jsonrpc.jsonrpc import JsonRPCRequest # noqa | ||
from nibiru.jsonrpc.jsonrpc import JsonRPCResponse # noqa | ||
from nibiru.jsonrpc.jsonrpc import do_jsonrpc_request # noqa | ||
from nibiru.jsonrpc.jsonrpc import do_jsonrpc_request_raw # noqa | ||
from nibiru.jsonrpc.jsonrpc import json_rpc_request_keys # noqa | ||
from nibiru.jsonrpc.jsonrpc import json_rpc_response_keys # noqa |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,250 @@ | ||
import dataclasses | ||
from typing import Any, Dict, Optional, Set, Union | ||
|
||
import requests | ||
|
||
from nibiru import pytypes | ||
from nibiru.jsonrpc import rpc_error | ||
|
||
|
||
def json_rpc_request_keys() -> Set[str]: | ||
"""Fields for a JSONRPCRequest. Must be one of: | ||
["method", "params", "jsonrpc", "id"] | ||
""" | ||
return set(["method", "params", "jsonrpc", "id"]) | ||
|
||
|
||
def json_rpc_response_keys() -> Set[str]: | ||
"""Fields for a JSONRPCResponse. Must be one of: | ||
["result", "error", "jsonrpc", "id"] | ||
""" | ||
return set(["result", "error", "jsonrpc", "id"]) | ||
|
||
|
||
JsonRPCID = Union[str, int] | ||
|
||
|
||
@dataclasses.dataclass | ||
class JsonRPCRequest: | ||
method: str | ||
params: pytypes.Jsonable = None | ||
jsonrpc: str = "2.0" | ||
id: Optional[JsonRPCID] = None | ||
|
||
def __post_init__(self): | ||
self._validate_method(method=self.method) | ||
self._validate_id(id=self.id) | ||
|
||
@staticmethod | ||
def _validate_method(method: str): | ||
if not isinstance(method, str): | ||
raise ValueError("Method must be a string.") | ||
if method.startswith("rpc."): | ||
raise ValueError("Method names beginning with 'rpc.' are reserved.") | ||
|
||
@staticmethod | ||
def _validate_id(id: Optional[Union[str, int]]): | ||
id_ = id | ||
if id_ is not None and not isinstance(id_, (str, int, type(None))): | ||
raise ValueError("id must be a string, number, or None.") | ||
if isinstance(id_, int) and id_ != int(id_): | ||
raise ValueError("id as number should not contain fractional parts.") | ||
|
||
def to_dict(self) -> Dict[str, Any]: | ||
request = {"jsonrpc": self.jsonrpc, "method": self.method} | ||
if self.params is not None: | ||
request["params"] = self.params | ||
if self.id is not None: | ||
request["id"] = self.id | ||
return request | ||
|
||
@classmethod | ||
def from_raw_dict(cls, raw: "RawJsonRPCRequest") -> "JsonRPCRequest": | ||
# Make sure the raw data is a dictionary | ||
if not isinstance(raw, dict): | ||
raise TypeError(f"Expected dict, got {type(raw)}") | ||
|
||
# Check for the required fields | ||
for field in ['jsonrpc', 'method']: | ||
if field not in raw and field != "jsonrpc": | ||
raise ValueError(f"Missing required field {field}") | ||
elif field == "jsonrpc": | ||
raw["jsonrpc"] = cls.jsonrpc | ||
|
||
# Create a JsonRPCRequest object from the raw dictionary | ||
jsonrpc = raw.get('jsonrpc') | ||
method = raw.get('method') | ||
params = raw.get('params', None) | ||
id = raw.get('id', None) | ||
return cls(method=method, params=params, id=id, jsonrpc=jsonrpc) | ||
|
||
|
||
# from typing import TypedDict # not available in Python 3.7 | ||
# class RawJsonRPCRequest(TypedDict): | ||
class RawJsonRPCRequest(dict): | ||
"""Proxy for a 'TypedDict' representing a JSON RPC response. | ||
The 'JsonRPCRequest' type is defined according to the official | ||
[JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). | ||
Keys (ValueType): | ||
method (str): 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 (TODO): A structured value that holds the parameter values to be | ||
used during the invocation of the method. This field MAY be omitted. | ||
jsonrpc (str): Specifies the version of the JSON-RPC protocol. | ||
MUST be exactly "2.0". | ||
id (str): 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. | ||
1. The value SHOULD normally not be Null: 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. The Numbers in the id SHOULD NOT contain fractional parts: | ||
Fractional parts may be problematic, since many decimal fractions | ||
cannot be represented exactly as binary fractions. | ||
Note that 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. | ||
""" | ||
|
||
|
||
# from typing import TypedDict # not available in Python 3.7 | ||
# class RawJsonRPCResponse(TypedDict): | ||
class RawJsonRPCResponse(dict): | ||
"""Proxy for a 'TypedDict' representing a JSON RPC response. | ||
The 'JsonRPCResponse' type is defined according to the official | ||
[JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). | ||
Keys (ValueType): | ||
result (TODO): ... | ||
error (TODO): ... | ||
jsonrpc (str): Should be "2.0". | ||
id (str): block height at which the transaction was committed. | ||
""" | ||
|
||
|
||
@dataclasses.dataclass | ||
class JsonRPCResponse: | ||
"""Generic JSON-RPC response as dictated by the official | ||
[JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification). | ||
Args and Attributes: | ||
id (JsonRPCID): | ||
jsonrpc (str = "2.0"): | ||
result (TODO, optional): Defaults to None. | ||
error (TODO, optional): Defaults to None. | ||
""" | ||
|
||
id: Optional[JsonRPCID] = None | ||
jsonrpc: str = "2.0" | ||
result: Any = None | ||
error: Any = None | ||
|
||
def __post_init__(self): | ||
self._validate(result=self.result, error=self.error) | ||
|
||
def _validate(self, result, error): | ||
if result is not None and error is not None: | ||
raise ValueError("Both result and error cannot be set.") | ||
elif result is None and error is None: | ||
raise ValueError("Either result or error must be set.") | ||
elif result is not None: | ||
self.result = result | ||
self.error = None | ||
elif error is not None: | ||
if not isinstance(error, rpc_error.RPCError): | ||
raise ValueError("Error must be an instance of RPCError.") | ||
self.error = error.to_dict() | ||
self.result = None | ||
|
||
def to_dict(self) -> RawJsonRPCResponse: | ||
response: dict = {"jsonrpc": self.jsonrpc, "id": self.id} | ||
if self.result is not None: | ||
response["result"] = self.result | ||
if self.error is not None: | ||
response["error"] = self.error | ||
return response | ||
|
||
@classmethod | ||
def from_raw_dict(cls, raw: "RawJsonRPCResponse") -> "JsonRPCResponse": | ||
# Make sure the raw data is a dictionary | ||
if not isinstance(raw, dict): | ||
raise TypeError(f"Expected dict, got {type(raw)}") | ||
|
||
# Check for the required fields | ||
for field in ['jsonrpc', 'id']: | ||
if field not in raw: | ||
raise ValueError(f"Missing required field {field}") | ||
|
||
# Create a JsonRPCResponse object from the raw dictionary | ||
jsonrpc = raw.get('jsonrpc') | ||
id = raw.get('id') | ||
result = raw.get('result', None) | ||
error = raw.get('error', None) | ||
|
||
# Make sure either result or error is present | ||
if result is None and error is None: | ||
raise ValueError("Either result or error must be present.") | ||
if result is not None and error is not None: | ||
raise ValueError("Both result and error cannot be present.") | ||
|
||
# If an error is present, make sure it is an RPCError | ||
if error is not None: | ||
error = rpc_error.RPCError.from_dict(error) | ||
|
||
return cls(jsonrpc=jsonrpc, id=id, result=result, error=error) | ||
|
||
def __eq__(self, other) -> bool: | ||
return all( | ||
[ | ||
self.id == other.id, | ||
self.jsonrpc == other.jsonrpc, | ||
self.result == other.result, | ||
self.error == other.error, | ||
] | ||
) | ||
|
||
def ok(self) -> bool: | ||
return all([self.error is None, self.result is not None]) | ||
|
||
|
||
def do_jsonrpc_request( | ||
data: Union[JsonRPCRequest, RawJsonRPCRequest], | ||
endpoint: str = "http://localhost:26657", | ||
headers: Dict[str, str] = {"Content-Type": "application/json"}, | ||
) -> JsonRPCResponse: | ||
return JsonRPCResponse.from_raw_dict( | ||
raw=do_jsonrpc_request_raw( | ||
data=data, | ||
endpoint=endpoint, | ||
headers=headers, | ||
) | ||
) | ||
|
||
|
||
def do_jsonrpc_request_raw( | ||
data: Union[JsonRPCRequest, RawJsonRPCRequest], | ||
endpoint: str = "http://localhost:26657", | ||
headers: Dict[str, str] = {"Content-Type": "application/json"}, | ||
) -> RawJsonRPCResponse: | ||
if isinstance(data, dict): | ||
data = JsonRPCRequest.from_raw_dict(data) | ||
elif isinstance(data, JsonRPCRequest): | ||
... | ||
# elif TODO: feat: add a fn that checks the attrs at runtime to | ||
# assemble a valid JsonRPCRequest even if the class type is not dict | ||
# or JSONRPCRequest | ||
|
||
resp: requests.Response = requests.post( | ||
url=endpoint, json=data.to_dict(), headers=headers | ||
) | ||
return resp.json() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
import json | ||
from typing import Callable, Dict, Tuple, Union | ||
|
||
import pytest | ||
|
||
from nibiru.jsonrpc import jsonrpc, rpc_error | ||
|
||
|
||
def mock_rpc_method_subtract(params): | ||
if isinstance(params, list): | ||
return params[0] - params[1] | ||
elif isinstance(params, dict): | ||
return params["minuend"] - params["subtrahend"] | ||
|
||
|
||
MOCK_METHODS: Dict[str, Callable] = {"subtract": mock_rpc_method_subtract} | ||
|
||
|
||
def handle_request(request_json: str) -> jsonrpc.JsonRPCResponse: | ||
try: | ||
request_dict = json.loads(request_json) | ||
except json.JSONDecodeError: | ||
return jsonrpc.JsonRPCResponse( | ||
error=rpc_error.ParseError(), | ||
result=None, | ||
) | ||
|
||
try: | ||
request = jsonrpc.JsonRPCRequest.from_raw_dict(request_dict) | ||
except ValueError: | ||
return jsonrpc.JsonRPCResponse( | ||
error=rpc_error.InvalidRequestError(), | ||
result=None, | ||
id=request_dict.get("id"), | ||
) | ||
|
||
method: Union[Callable, None] = MOCK_METHODS.get(request.method) | ||
|
||
if method is None: | ||
return jsonrpc.JsonRPCResponse( | ||
id=request.id, error=rpc_error.MethodNotFoundError() | ||
) | ||
result = method(request.params) | ||
return jsonrpc.JsonRPCResponse(id=request.id, result=result) | ||
|
||
|
||
def rpc_call_test_case(req: str, resp: str) -> Tuple[str, str]: | ||
assert isinstance(req, str) | ||
assert isinstance(resp, str) | ||
return (req, resp) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"request_json, response_json", | ||
[ | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', | ||
resp='{"jsonrpc": "2.0", "result": 19, "id": 1}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', | ||
resp='{"jsonrpc": "2.0", "result": -19, "id": 2}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}', | ||
resp='{"jsonrpc": "2.0", "result": 19, "id": 3}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}', | ||
resp='{"jsonrpc": "2.0", "result": 19, "id": 4}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "foobar", "id": "1"}', | ||
resp='{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]', | ||
resp='{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}', | ||
), | ||
rpc_call_test_case( | ||
req='{"jsonrpc": "2.0", "method": 1, "params": "bar"}', | ||
resp='{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}', | ||
), | ||
], | ||
) | ||
def test_rpc_calls(request_json: str, response_json: str): | ||
got_resp: jsonrpc.JsonRPCResponse = handle_request(request_json) | ||
want_resp = jsonrpc.JsonRPCResponse.from_raw_dict( | ||
raw=json.loads(response_json), | ||
) | ||
|
||
# Manually check equals | ||
assert got_resp.id == want_resp.id | ||
assert got_resp.jsonrpc == want_resp.jsonrpc | ||
assert got_resp.result == want_resp.result | ||
assert got_resp.error == want_resp.error | ||
|
||
# Check with __eq__ method | ||
assert got_resp == want_resp | ||
|
||
|
||
def test_rpc_block_query(): | ||
""" | ||
Runs the example query JSON-RPC query from the Tendermint documentation: | ||
The following exampl | ||
```bash | ||
curl --header "Content-Type: application/json" \ | ||
--request POST \ | ||
--data '{"method": "block" , "params": ["5"], "id": 1}' \ | ||
localhost:26657 | ||
``` | ||
Ref: https://docs.tendermint.com/v0.37/rpc/#/jsonrpc-http:~:text=block%3Fheight%3D5-,JSONRPC,-/HTTP | ||
""" | ||
|
||
jsonrpc_resp: jsonrpc.JsonRPCResponse = jsonrpc.do_jsonrpc_request( | ||
data=dict(method="block", params=["5"], id=1), | ||
) | ||
assert isinstance(jsonrpc_resp, jsonrpc.JsonRPCResponse) | ||
assert jsonrpc_resp.error is None | ||
assert jsonrpc_resp.result | ||
assert jsonrpc.JsonRPCResponse.from_raw_dict(raw=jsonrpc_resp.to_dict()) |
Oops, something went wrong.