Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,4 @@ dmypy.json

# MacOS artifacts
.DS_Store
.jrb/ ..
Empty file.
77 changes: 77 additions & 0 deletions tests/unit/asyn/clients/test_json_rpc_base.py
Original file line number Diff line number Diff line change
@@ -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())
17 changes: 13 additions & 4 deletions xrpl/asyncio/clients/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Optional
from typing import Optional, Dict

from typing_extensions import Final, Self

Expand All @@ -24,14 +24,24 @@ class Client(ABC):
:meta private:
"""

def __init__(self: Self, url: str) -> None:
def __init__(
self: Self,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
) -> None:
"""
Initializes a client.

Arguments:
url: The url to which this client will connect
url: The URL to which this client will connect.
headers: Optional dictionary of default headers to include with each request.
These can be used to authenticate with private XRPL nodes or pass
custom metadata, such as:
- {"Authorization": "Bearer <token>"}
"""
self.url = url
self.headers = headers or {}
self.network_id: Optional[int] = None
self.build_version: Optional[str] = None

Expand Down Expand Up @@ -66,7 +76,6 @@ async def get_network_id_and_build_version(client: Client) -> None:
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

Expand Down
6 changes: 6 additions & 0 deletions xrpl/asyncio/clients/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ class XRPLWebsocketException(XRPLException):
"""

pass


class XRPLAuthenticationException(XRPLRequestFailureException):
"""Raised when authentication with the XRPL node fails (401 or 403)."""

pass
34 changes: 31 additions & 3 deletions xrpl/asyncio/clients/json_rpc_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from json import JSONDecodeError
from typing import Optional, Dict

from httpx import AsyncClient
from typing_extensions import Self
Expand All @@ -21,16 +22,35 @@ class JsonRpcBase(Client):
:meta private:
"""

def __init__(
self: Self,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a similar update to the WebSocket class's constructor?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would updating Client supersede this?

) -> None:
"""
Initializes a new JsonRpcBase client.

Arguments:
url: The URL of the XRPL node to connect to.
headers: Optional default headers for all requests (e.g. API key or Dhali payment-claim).
"""
super().__init__(url, headers=headers)
async def _request_impl(
self: Self, request: Request, *, timeout: float = REQUEST_TIMEOUT
self: Self,
request: Request,
*,
timeout: float = REQUEST_TIMEOUT,
headers: Optional[Dict[str, str]] = None,
) -> 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.
rippled server.
headers: Optional additional headers to include for this request.

Returns:
The response from the server, as a Response object.
Expand All @@ -40,10 +60,18 @@ async def _request_impl(

:meta private:
"""
# Merge global and per-request headers
merged_headers = {
"Content-Type": "application/json",
**self.headers,
**(headers or {}),
}

async with AsyncClient(timeout=timeout) as http_client:
response = await http_client.post(
self.url,
json=request_to_json_rpc(request),
headers=merged_headers,
)
try:
return json_to_response(response.json())
Expand All @@ -53,4 +81,4 @@ async def _request_impl(
"error": response.status_code,
"error_message": response.text,
}
)
) from None