diff --git a/.gitignore b/.gitignore index 28809ddf1..f1812cd40 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ dmypy.json # MacOS artifacts .DS_Store +.jrb/ .. diff --git a/tests/unit/asyn/clients/__init__.py b/tests/unit/asyn/clients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/asyn/clients/test_json_rpc_base.py b/tests/unit/asyn/clients/test_json_rpc_base.py new file mode 100644 index 000000000..a22faf82c --- /dev/null +++ b/tests/unit/asyn/clients/test_json_rpc_base.py @@ -0,0 +1,77 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import Response + +from xrpl.asyncio.clients.exceptions import XRPLAuthenticationException +from xrpl.asyncio.clients.json_rpc_base import JsonRpcBase +from xrpl.models.requests import ServerInfo + + +@pytest.mark.asyncio +async def test_global_headers_are_sent(): + client = JsonRpcBase( + "https://xrpl.fake", headers={"Authorization": "Bearer testtoken"} + ) + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = Response( + status_code=200, + json={"result": {"status": "success"}, "id": 1}, + ) + + await client._request_impl(ServerInfo()) + + headers_sent = mock_post.call_args.kwargs["headers"] + assert headers_sent["Authorization"] == "Bearer testtoken" + assert headers_sent["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_per_request_headers_override_global(): + client = JsonRpcBase( + "https://xrpl.fake", headers={"Authorization": "Bearer default"} + ) + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = Response( + status_code=200, + json={"result": {"status": "success"}, "id": 1}, + ) + + await client._request_impl( + ServerInfo(), headers={"Authorization": "Bearer override"} + ) + + headers_sent = mock_post.call_args.kwargs["headers"] + assert headers_sent["Authorization"] == "Bearer override" + + +@pytest.mark.asyncio +async def test_no_headers_does_not_crash(): + client = JsonRpcBase("https://xrpl.fake") + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = Response( + status_code=200, + json={"result": {"status": "success"}, "id": 1}, + ) + + await client._request_impl(ServerInfo()) + + headers_sent = mock_post.call_args.kwargs["headers"] + assert headers_sent["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_raises_on_401_403(): + client = JsonRpcBase("https://xrpl.fake") + + for code in [401, 403]: + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = Response(status_code=code, text="Unauthorized") + + with pytest.raises( + XRPLAuthenticationException, match="Authentication failed" + ): + await client._request_impl(ServerInfo()) diff --git a/xrpl/asyncio/clients/client.py b/xrpl/asyncio/clients/client.py deleted file mode 100644 index ed92f602b..000000000 --- a/xrpl/asyncio/clients/client.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Interface for all network clients to follow.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Optional - -from typing_extensions import Final, Self - -from xrpl.asyncio.clients.exceptions import XRPLRequestFailureException -from xrpl.models.requests import ServerInfo -from xrpl.models.requests.request import Request -from xrpl.models.response import Response - -# The default request timeout duration. Set in Client._request_impl to allow more time -# for longer running commands. -REQUEST_TIMEOUT: Final[float] = 10.0 - - -class Client(ABC): - """ - Interface for all network clients to follow. - - :meta private: - """ - - def __init__(self: Self, url: str) -> None: - """ - Initializes a client. - - Arguments: - url: The url to which this client will connect - """ - self.url = url - self.network_id: Optional[int] = None - self.build_version: Optional[str] = None - - @abstractmethod - async def _request_impl( - self: Self, request: Request, *, timeout: float = REQUEST_TIMEOUT - ) -> Response: - """ - This is the actual driver for a given Client's request. It must be - async because all of the helper functions in this library are - async-first. Implement this in a given Client. - - Arguments: - request: An object representing information about a rippled request. - timeout: The maximum tolerable delay on waiting for a response. - - Returns: - The response from the server, as a Response object. - - :meta private: - """ - pass - - -async def get_network_id_and_build_version(client: Client) -> None: - """ - Get the network id and build version of the connected server. - - Args: - client: The network client to use to send the request. - - Raises: - XRPLRequestFailureException: if the rippled API call fails. - """ - # the required values are already present, no need for further processing - if client.network_id and client.build_version: - return - - response = await client._request_impl(ServerInfo()) - if response.is_successful(): - if "network_id" in response.result["info"]: - client.network_id = response.result["info"]["network_id"] - if not client.build_version and "build_version" in response.result["info"]: - client.build_version = response.result["info"]["build_version"] - return - - raise XRPLRequestFailureException(response.result) diff --git a/xrpl/asyncio/clients/exceptions.py b/xrpl/asyncio/clients/exceptions.py index 82b664220..788fc8178 100644 --- a/xrpl/asyncio/clients/exceptions.py +++ b/xrpl/asyncio/clients/exceptions.py @@ -36,3 +36,9 @@ class XRPLWebsocketException(XRPLException): """ pass + + +class XRPLAuthenticationException(XRPLRequestFailureException): + """Raised when authentication with the XRPL node fails (401 or 403).""" + + pass diff --git a/xrpl/asyncio/clients/json_rpc_base.py b/xrpl/asyncio/clients/json_rpc_base.py deleted file mode 100644 index be6eb0605..000000000 --- a/xrpl/asyncio/clients/json_rpc_base.py +++ /dev/null @@ -1,56 +0,0 @@ -"""A common interface for JsonRpc requests.""" - -from __future__ import annotations - -from json import JSONDecodeError - -from httpx import AsyncClient -from typing_extensions import Self - -from xrpl.asyncio.clients.client import REQUEST_TIMEOUT, Client -from xrpl.asyncio.clients.exceptions import XRPLRequestFailureException -from xrpl.asyncio.clients.utils import json_to_response, request_to_json_rpc -from xrpl.models.requests.request import Request -from xrpl.models.response import Response - - -class JsonRpcBase(Client): - """ - A common interface for JsonRpc requests. - - :meta private: - """ - - async def _request_impl( - self: Self, request: Request, *, timeout: float = REQUEST_TIMEOUT - ) -> Response: - """ - Base ``_request_impl`` implementation for JSON RPC. - - Arguments: - request: An object representing information about a rippled request. - timeout: The duration within which we expect to hear a response from the - rippled validator. - - Returns: - The response from the server, as a Response object. - - Raises: - XRPLRequestFailureException: if response can't be JSON decoded. - - :meta private: - """ - async with AsyncClient(timeout=timeout) as http_client: - response = await http_client.post( - self.url, - json=request_to_json_rpc(request), - ) - try: - return json_to_response(response.json()) - except JSONDecodeError: - raise XRPLRequestFailureException( - { - "error": response.status_code, - "error_message": response.text, - } - )