Split RPCs into a separate crate (#1910)

* WIP extract RPCs into separate crate

* fmt

* Fix test

* Remove unused deps

* fix import

* WIP: Fix up errors and most tests. Start extracintg some tests/code to rpc crate

* MockRpcClient sync or async

* MockRpcClient only async but better type inference

* WIP MockRpcClient FnMuts and some test updates to use it

* Get all but one test working with new MockRpcClient

* WIP trying to debug failure

* WIP, Tests mostly fixed, need to add back oen more

* Get mock RPC tests working

* fmt

* fmt

* Clippy and comment tweak

* update CI to explicitly check subxt-rpc features

* clippy

* small tweaks after pass over

* feature flag rename

* update some docs

* Fix some examples

* fmt

* Fix features flags to work with web/wasm32

* Fix unused dep warning

* explicit targets in wasm CI

* Add better crate level docs

* fmt

* Address review comments

* Comment out flaky test for now and make more obvious how similar POlkadot and Substrate configs are

* Not a doc comment

* Remove unused imports
This commit is contained in:
James Wilson
2025-02-18 12:07:00 +00:00
committed by GitHub
parent 333de953ec
commit 816a86423b
50 changed files with 4575 additions and 1186 deletions
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use super::{RawRpcFuture, RawRpcSubscription, RpcClientT};
use crate::Error;
use futures::stream::{StreamExt, TryStreamExt};
use jsonrpsee::{
core::{
client::{Error as JsonrpseeError, Client, ClientT, SubscriptionClientT, SubscriptionKind},
traits::ToRpcParams,
},
types::SubscriptionId,
};
use serde_json::value::RawValue;
struct Params(Option<Box<RawValue>>);
impl ToRpcParams for Params {
fn to_rpc_params(self) -> Result<Option<Box<RawValue>>, serde_json::Error> {
Ok(self.0)
}
}
impl RpcClientT for Client {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>> {
Box::pin(async move {
let res = ClientT::request(self, method, Params(params)).await?;
Ok(res)
})
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
Box::pin(async move {
let stream = SubscriptionClientT::subscribe::<Box<RawValue>, _>(
self,
sub,
Params(params),
unsub,
).await?;
let id = match stream.kind() {
SubscriptionKind::Subscription(SubscriptionId::Str(id)) => {
Some(id.clone().into_owned())
}
_ => None,
};
let stream = stream
.map_err(|e| Error::Client(Box::new(e)))
.boxed();
Ok(RawRpcSubscription { stream, id })
})
}
}
// Convert a JsonrpseeError into the RPC error in this crate.
// The main reason for this is to capture user errors so that
// they can be represented/handled without casting.
impl From<JsonrpseeError> for Error {
fn from(error: JsonrpseeError) -> Self {
match error {
JsonrpseeError::Call(e) => {
Error::User(crate::UserError {
code: e.code(),
message: e.message().to_owned(),
data: e.data().map(|d| d.to_owned())
})
},
e => {
Error::Client(Box::new(e))
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use super::{RawRpcFuture, RawRpcSubscription, RpcClientT};
use crate::Error;
use futures::stream::{StreamExt, TryStreamExt};
use serde_json::value::RawValue;
use subxt_lightclient::{LightClientRpc, LightClientRpcError};
impl RpcClientT for LightClientRpc {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>> {
Box::pin(async move {
let res = self.request(method.to_owned(), params)
.await?;
Ok(res)
})
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
Box::pin(async move {
let sub = self.subscribe(sub.to_owned(), params, unsub.to_owned())
.await?;
let id = Some(sub.id().to_owned());
let stream = sub
.map_err(|e| Error::Client(Box::new(e)))
.boxed();
Ok(RawRpcSubscription { id, stream })
})
}
}
impl From<LightClientRpcError> for Error {
fn from(err: LightClientRpcError) -> Error {
match err {
LightClientRpcError::JsonRpcError(e) => {
// If the error is a typical user error, report it as such, else
// just wrap the error into a ClientError.
let Ok(user_error) = e.try_deserialize() else {
return Error::Client(Box::<CoreError>::from(e))
};
Error::User(user_error)
},
LightClientRpcError::SmoldotError(e) => Error::Client(Box::<CoreError>::from(e)),
LightClientRpcError::BackgroundTaskDropped => Error::Client(Box::<CoreError>::from("Smoldot background task was dropped")),
}
}
}
type CoreError = dyn core::error::Error + Send + Sync + 'static;
+633
View File
@@ -0,0 +1,633 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
//! This module exposes a [`MockRpcClient`], which is useful for testing.
//!
//! # Example
//!
//! ```rust
//! use subxt_rpcs::client::{ RpcClient, MockRpcClient };
//! use subxt_rpcs::client::mock_rpc_client::Json;
//!
//! let mut state = vec![
//! Json(1u8),
//! Json(2u8),
//! Json(3u8),
//! ];
//!
//! // Define a mock client by providing some functions which intercept
//! // method and subscription calls and return some response.
//! let mock_client = MockRpcClient::builder()
//! .method_handler_once("foo", move |params| {
//! // Return each item from our state, and then null afterwards.
//! let val = state.pop();
//! async move { val }
//! })
//! .subscription_handler("bar", |params, unsub| async move {
//! // Arrays, vecs or an RpcSubscription can be returned here to
//! // signal the set of values to be handed back on a subscription.
//! vec![Json(1), Json(2), Json(3)]
//! })
//! .build();
//!
//! // Build an RPC Client that can be used in Subxt or in conjunction with
//! // the RPC methods provided in this crate.
//! let rpc_client = RpcClient::new(mock_client);
//! ```
use super::{RpcClientT, RawRpcFuture, RawRpcSubscription};
use crate::{Error, UserError};
use core::future::Future;
use futures::StreamExt;
use serde_json::value::RawValue;
use std::sync::{Arc, Mutex};
use std::collections::{HashMap, VecDeque};
type MethodHandlerFnOnce = Box<dyn FnOnce(&str, Option<Box<serde_json::value::RawValue>>) -> RawRpcFuture<'static, Box<RawValue>> + Send + Sync + 'static>;
type SubscriptionHandlerFnOnce = Box<dyn FnOnce(&str, Option<Box<serde_json::value::RawValue>>, &str) -> RawRpcFuture<'static, RawRpcSubscription> + Send + Sync + 'static>;
type MethodHandlerFn = Box<dyn FnMut(&str, Option<Box<serde_json::value::RawValue>>) -> RawRpcFuture<'static, Box<RawValue>> + Send + Sync + 'static>;
type SubscriptionHandlerFn = Box<dyn FnMut(&str, Option<Box<serde_json::value::RawValue>>, &str) -> RawRpcFuture<'static, RawRpcSubscription> + Send + Sync + 'static>;
/// A builder to configure and build a new [`MockRpcClient`].
#[derive(Default)]
pub struct MockRpcClientBuilder {
method_handlers_once: HashMap<String, VecDeque<MethodHandlerFnOnce>>,
method_handlers: HashMap<String, MethodHandlerFn>,
method_fallback: Option<MethodHandlerFn>,
subscription_handlers_once: HashMap<String, VecDeque<SubscriptionHandlerFnOnce>>,
subscription_handlers: HashMap<String, SubscriptionHandlerFn>,
subscription_fallback: Option<SubscriptionHandlerFn>
}
impl MockRpcClientBuilder {
/// Add a handler for a specific RPC method. This is called exactly once, and multiple such calls for the same method can be
/// added. Only when any calls registered with this have been used up is the method set by [`Self::method_handler`] called.
pub fn method_handler_once<MethodHandler, MFut, MRes>(mut self, name: impl Into<String>, f: MethodHandler) -> Self
where
MethodHandler: FnOnce(Option<Box<serde_json::value::RawValue>>) -> MFut + Send + Sync + 'static,
MFut: Future<Output = MRes> + Send + 'static,
MRes: IntoHandlerResponse,
{
let handler: MethodHandlerFnOnce = Box::new(move |_method: &str, params: Option<Box<serde_json::value::RawValue>>| {
let fut = f(params);
Box::pin(async move { fut.await.into_handler_response() })
});
self.method_handlers_once.entry(name.into()).or_default().push_back(handler);
self
}
/// Add a handler for a specific RPC method.
pub fn method_handler<MethodHandler, MFut, MRes>(mut self, name: impl Into<String>, mut f: MethodHandler) -> Self
where
MethodHandler: FnMut(Option<Box<serde_json::value::RawValue>>) -> MFut + Send + Sync + 'static,
MFut: Future<Output = MRes> + Send + 'static,
MRes: IntoHandlerResponse,
{
let handler: MethodHandlerFn = Box::new(move |_method: &str, params: Option<Box<serde_json::value::RawValue>>| {
let fut = f(params);
Box::pin(async move { fut.await.into_handler_response() })
});
self.method_handlers.insert(name.into(), handler);
self
}
/// Add a fallback handler to handle any methods not handled by a specific handler.
pub fn method_fallback<MethodHandler, MFut, MRes>(mut self, mut f: MethodHandler) -> Self
where
MethodHandler: FnMut(String, Option<Box<serde_json::value::RawValue>>) -> MFut + Send + Sync + 'static,
MFut: Future<Output = MRes> + Send + 'static,
MRes: IntoHandlerResponse,
{
let handler: MethodHandlerFn = Box::new(move |method: &str, params: Option<Box<serde_json::value::RawValue>>| {
let fut = f(method.to_owned(), params);
Box::pin(async move { fut.await.into_handler_response() })
});
self.method_fallback = Some(handler);
self
}
/// Add a handler for a specific RPC subscription.
pub fn subscription_handler_once<SubscriptionHandler, SFut, SRes>(mut self, name: impl Into<String>, f: SubscriptionHandler) -> Self
where
SubscriptionHandler: FnOnce(Option<Box<serde_json::value::RawValue>>, String) -> SFut + Send + Sync + 'static,
SFut: Future<Output = SRes> + Send + 'static,
SRes: IntoSubscriptionResponse,
{
let handler: SubscriptionHandlerFnOnce = Box::new(move |_sub: &str, params: Option<Box<serde_json::value::RawValue>>, unsub: &str| {
let fut = f(params, unsub.to_owned());
Box::pin(async move { fut.await.into_subscription_response() })
});
self.subscription_handlers_once.entry(name.into()).or_default().push_back(handler);
self
}
/// Add a handler for a specific RPC subscription.
pub fn subscription_handler<SubscriptionHandler, SFut, SRes>(mut self, name: impl Into<String>, mut f: SubscriptionHandler) -> Self
where
SubscriptionHandler: FnMut(Option<Box<serde_json::value::RawValue>>, String) -> SFut + Send + Sync + 'static,
SFut: Future<Output = SRes> + Send + 'static,
SRes: IntoSubscriptionResponse,
{
let handler: SubscriptionHandlerFn = Box::new(move |_sub: &str, params: Option<Box<serde_json::value::RawValue>>, unsub: &str| {
let fut = f(params, unsub.to_owned());
Box::pin(async move { fut.await.into_subscription_response() })
});
self.subscription_handlers.insert(name.into(), handler);
self
}
/// Add a fallback handler to handle any subscriptions not handled by a specific handler.
pub fn subscription_fallback<SubscriptionHandler, SFut, SRes>(mut self, mut f: SubscriptionHandler) -> Self
where
SubscriptionHandler: FnMut(String, Option<Box<serde_json::value::RawValue>>, String) -> SFut + Send + Sync + 'static,
SFut: Future<Output = SRes> + Send + 'static,
SRes: IntoSubscriptionResponse,
{
let handler: SubscriptionHandlerFn = Box::new(move |sub: &str, params: Option<Box<serde_json::value::RawValue>>, unsub: &str| {
let fut = f(sub.to_owned(), params, unsub.to_owned());
Box::pin(async move { fut.await.into_subscription_response() })
});
self.subscription_fallback = Some(handler);
self
}
/// Construct a [`MockRpcClient`] given some state which will be mutably available to each of the handlers.
pub fn build(self) -> MockRpcClient {
MockRpcClient {
method_handlers_once: Arc::new(Mutex::new(self.method_handlers_once)),
method_handlers: Arc::new(Mutex::new(self.method_handlers)),
method_fallback: self.method_fallback.map(|f| Arc::new(Mutex::new(f))),
subscription_handlers_once: Arc::new(Mutex::new(self.subscription_handlers_once)),
subscription_handlers: Arc::new(Mutex::new(self.subscription_handlers)),
subscription_fallback: self.subscription_fallback.map(|f| Arc::new(Mutex::new(f))),
}
}
}
/// A mock RPC client that responds programmatically to requests.
/// Useful for testing.
#[derive(Clone)]
pub struct MockRpcClient {
// These are all accessed for just long enough to call the method. The method
// returns a future, but the method call itself isn't held for long.
method_handlers_once: Arc<Mutex<HashMap<String, VecDeque<MethodHandlerFnOnce>>>>,
method_handlers: Arc<Mutex<HashMap<String, MethodHandlerFn>>>,
method_fallback: Option<Arc<Mutex<MethodHandlerFn>>>,
subscription_handlers_once: Arc<Mutex<HashMap<String, VecDeque<SubscriptionHandlerFnOnce>>>>,
subscription_handlers: Arc<Mutex<HashMap<String, SubscriptionHandlerFn>>>,
subscription_fallback: Option<Arc<Mutex<SubscriptionHandlerFn>>>,
}
impl MockRpcClient {
/// Construct a new [`MockRpcClient`]
pub fn builder() -> MockRpcClientBuilder {
MockRpcClientBuilder::default()
}
}
impl RpcClientT for MockRpcClient {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<serde_json::value::RawValue>>,
) -> RawRpcFuture<'a, Box<serde_json::value::RawValue>> {
// Remove and call a one-time handler if any exist.
let mut handlers_once = self.method_handlers_once.lock().unwrap();
if let Some(handlers) = handlers_once.get_mut(method) {
if let Some(handler) = handlers.pop_front() {
return handler(method, params)
}
}
drop(handlers_once);
// Call a specific handler for the method if one is found.
let mut handlers = self.method_handlers.lock().unwrap();
if let Some(handler) = handlers.get_mut(method) {
return handler(method, params)
}
drop(handlers);
// Call a fallback handler if one exists
if let Some(handler) = &self.method_fallback {
let mut handler = handler.lock().unwrap();
return handler(method, params)
}
// Else, method not found.
Box::pin(async move { Err(UserError::method_not_found().into()) })
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<serde_json::value::RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
// Remove and call a one-time handler if any exist.
let mut handlers_once = self.subscription_handlers_once.lock().unwrap();
if let Some(handlers) = handlers_once.get_mut(sub) {
if let Some(handler) = handlers.pop_front() {
return handler(sub, params, unsub)
}
}
drop(handlers_once);
// Call a specific handler for the subscrpition if one is found.
let mut handlers = self.subscription_handlers.lock().unwrap();
if let Some(handler) = handlers.get_mut(sub) {
return handler(sub, params, unsub)
}
drop(handlers);
// Call a fallback handler if one exists
if let Some(handler) = &self.subscription_fallback {
let mut handler = handler.lock().unwrap();
return handler(sub, params, unsub)
}
// Else, method not found.
Box::pin(async move { Err(UserError::method_not_found().into()) })
}
}
/// Return responses wrapped in this to have them serialized to JSON.
pub struct Json<T>(pub T);
impl Json<serde_json::Value> {
/// Create a [`Json<serde_json::Value>`] from some serializable value.
/// Useful when value types are heterogenous.
pub fn value_of<T: serde::Serialize>(item: T) -> Self {
Json(serde_json::to_value(item).expect("item cannot be converted to a serde_json::Value"))
}
}
/// Anything that can be converted into a valid handler response implements this.
pub trait IntoHandlerResponse {
/// Convert self into a handler response.
fn into_handler_response(self) -> Result<Box<RawValue>, Error>;
}
impl <T: IntoHandlerResponse> IntoHandlerResponse for Result<T, Error> {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
self.and_then(|val| val.into_handler_response())
}
}
impl <T: IntoHandlerResponse> IntoHandlerResponse for Option<T> {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
self.ok_or_else(|| UserError::method_not_found().into())
.and_then(|val| val.into_handler_response())
}
}
impl IntoHandlerResponse for Box<RawValue> {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
Ok(self)
}
}
impl IntoHandlerResponse for serde_json::Value {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
serialize_to_raw_value(&self)
}
}
impl <T: serde::Serialize> IntoHandlerResponse for Json<T> {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
serialize_to_raw_value(&self.0)
}
}
impl IntoHandlerResponse for core::convert::Infallible {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
match self {}
}
}
fn serialize_to_raw_value<T: serde::Serialize>(val: &T) -> Result<Box<RawValue>, Error> {
let res = serde_json::to_string(val).map_err(Error::Deserialization)?;
let raw_value = RawValue::from_string(res).map_err(Error::Deserialization)?;
Ok(raw_value)
}
/// Anything that can be a response to a subscription handler implements this.
pub trait IntoSubscriptionResponse {
/// Convert self into a handler response.
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error>;
}
// A tuple of a subscription plus some string is treated as a subscription with that string ID.
impl <T: IntoSubscriptionResponse, S: Into<String>> IntoSubscriptionResponse for (T, S) {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
self.0
.into_subscription_response()
.map(|mut r| {
r.id = Some(self.1.into());
r
})
}
}
impl <T: IntoHandlerResponse + Send + 'static> IntoSubscriptionResponse for tokio::sync::mpsc::Receiver<T> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
struct IntoStream<T>(tokio::sync::mpsc::Receiver<T>);
impl <T> futures::Stream for IntoStream<T> {
type Item = T;
fn poll_next(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Option<Self::Item>> {
self.0.poll_recv(cx)
}
}
Ok(RawRpcSubscription {
stream: Box::pin(IntoStream(self).map(|item| item.into_handler_response())),
id: None,
})
}
}
impl <T: IntoHandlerResponse + Send + 'static> IntoSubscriptionResponse for tokio::sync::mpsc::UnboundedReceiver<T> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
struct IntoStream<T>(tokio::sync::mpsc::UnboundedReceiver<T>);
impl <T> futures::Stream for IntoStream<T> {
type Item = T;
fn poll_next(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Option<Self::Item>> {
self.0.poll_recv(cx)
}
}
Ok(RawRpcSubscription {
stream: Box::pin(IntoStream(self).map(|item| item.into_handler_response())),
id: None,
})
}
}
impl IntoSubscriptionResponse for RawRpcSubscription {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
Ok(self)
}
}
impl <T: IntoSubscriptionResponse> IntoSubscriptionResponse for Result<T, Error> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
self.and_then(|res| res.into_subscription_response())
}
}
impl <T: IntoHandlerResponse + Send + 'static> IntoSubscriptionResponse for Vec<T> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
let iter = self.into_iter().map(|item| item.into_handler_response());
Ok(RawRpcSubscription {
stream: Box::pin(futures::stream::iter(iter)),
id: None,
})
}
}
impl <T: IntoSubscriptionResponse + Send + 'static> IntoSubscriptionResponse for Option<T> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
match self {
Some(sub) => {
sub.into_subscription_response()
},
None => {
Ok(RawRpcSubscription {
stream: Box::pin(futures::stream::empty()),
id: None,
})
}
}
}
}
impl <T: IntoHandlerResponse + Send + 'static, const N: usize> IntoSubscriptionResponse for [T; N] {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
let iter = self.into_iter().map(|item| item.into_handler_response());
Ok(RawRpcSubscription {
stream: Box::pin(futures::stream::iter(iter)),
id: None,
})
}
}
impl IntoSubscriptionResponse for core::convert::Infallible {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
match self {}
}
}
/// Send the first items and then the second items back on a subscription;
/// If any one of the responses is an error, we'll return the error.
/// If one response has an ID and the other doesn't, we'll use that ID.
pub struct AndThen<A, B>(pub A, pub B);
impl <A: IntoSubscriptionResponse, B: IntoSubscriptionResponse> IntoSubscriptionResponse for AndThen<A, B> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
let a_responses = self.0.into_subscription_response();
let b_responses = self.1.into_subscription_response();
match (a_responses, b_responses) {
(Err(a), _) => {
Err(a)
},
(_, Err(b)) => {
Err(b)
},
(Ok(mut a), Ok(b)) => {
a.stream = Box::pin(a.stream.chain(b.stream));
a.id = a.id.or(b.id);
Ok(a)
}
}
}
}
/// Send back either one response or the other.
pub enum Either<A, B> {
/// The first possibility.
A(A),
/// The second possibility.
B(B)
}
impl <A: IntoHandlerResponse, B: IntoHandlerResponse> IntoHandlerResponse for Either<A, B> {
fn into_handler_response(self) -> Result<Box<RawValue>, Error> {
match self {
Either::A(a) => a.into_handler_response(),
Either::B(b) => b.into_handler_response(),
}
}
}
impl <A: IntoSubscriptionResponse, B: IntoSubscriptionResponse> IntoSubscriptionResponse for Either<A, B> {
fn into_subscription_response(self) -> Result<RawRpcSubscription, Error> {
match self {
Either::A(a) => a.into_subscription_response(),
Either::B(b) => b.into_subscription_response(),
}
}
}
#[cfg(test)]
mod test {
use crate::{RpcClient, rpc_params};
use super::*;
#[tokio::test]
async fn test_method_params() {
let rpc_client = MockRpcClient::builder()
.method_handler("foo", |params| async {
Json(params)
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// We get back whatever params we give
let res: (i32,i32,i32) = rpc_client.request("foo", rpc_params![1, 2, 3]).await.unwrap();
assert_eq!(res, (1,2,3));
let res: (String,) = rpc_client.request("foo", rpc_params!["hello"]).await.unwrap();
assert_eq!(res, ("hello".to_owned(),));
}
#[tokio::test]
async fn test_method_handler_then_fallback() {
let rpc_client = MockRpcClient::builder()
.method_handler("foo", |_params| async {
Json(1)
})
.method_fallback(|name, _params| async {
Json(name)
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// Whenever we call "foo", we get 1 back.
for i in [1,1,1,1] {
let res: i32 = rpc_client.request("foo", rpc_params![]).await.unwrap();
assert_eq!(res, i);
}
// Whenever we call anything else, we get the name of the method back
for name in ["bar", "wibble", "steve"] {
let res: String = rpc_client.request(name, rpc_params![]).await.unwrap();
assert_eq!(res, name);
}
}
#[tokio::test]
async fn test_method_once_then_handler() {
let rpc_client = MockRpcClient::builder()
.method_handler_once("foo", |_params| async {
Json(1)
})
.method_handler("foo", |_params| async {
Json(2)
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// Check that we call the "once" one time and then the second after that.
for i in [1,2,2,2,2] {
let res: i32 = rpc_client.request("foo", rpc_params![]).await.unwrap();
assert_eq!(res, i);
}
}
#[tokio::test]
async fn test_method_once() {
let rpc_client = MockRpcClient::builder()
.method_handler_once("foo", |_params| async {
Json(1)
})
.method_handler_once("foo", |_params| async {
Json(2)
})
.method_handler_once("foo", |_params| async {
Json(3)
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// Check that each method is only called once, in the right order.
for i in [1,2,3] {
let res: i32 = rpc_client.request("foo", rpc_params![]).await.unwrap();
assert_eq!(res, i);
}
// Check that we get a "method not found" error afterwards.
let err = rpc_client.request::<i32>("foo", rpc_params![]).await.unwrap_err();
let not_found_code = UserError::method_not_found().code;
assert!(matches!(err, Error::User(u) if u.code == not_found_code));
}
#[tokio::test]
async fn test_subscription_once_then_handler_then_fallback() {
let rpc_client = MockRpcClient::builder()
.subscription_handler_once("foo", |_params, _unsub| async {
vec![Json(0), Json(0)]
})
.subscription_handler("foo", |_params, _unsub| async {
vec![Json(1), Json(2), Json(3)]
})
.subscription_fallback(|_name, _params, _unsub| async {
vec![Json(4)]
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// "foo" returns 0,0 the first time it's subscribed to
let sub = rpc_client.subscribe::<i32>("foo", rpc_params![], "unsub").await.unwrap();
let res: Vec<i32> = sub.map(|i| i.unwrap()).collect().await;
assert_eq!(res, vec![0,0]);
// then, "foo" returns 1,2,3 in subscription every other time
for _ in 1..5 {
let sub = rpc_client.subscribe::<i32>("foo", rpc_params![], "unsub").await.unwrap();
let res: Vec<i32> = sub.map(|i| i.unwrap()).collect().await;
assert_eq!(res, vec![1,2,3]);
}
// anything else returns 4
let sub = rpc_client.subscribe::<i32>("bar", rpc_params![], "unsub").await.unwrap();
let res: Vec<i32> = sub.map(|i| i.unwrap()).collect().await;
assert_eq!(res, vec![4]);
}
#[tokio::test]
async fn test_subscription_and_then_with_channel() {
let (tx, rx) = tokio::sync::mpsc::channel(10);
let rpc_client = MockRpcClient::builder()
.subscription_handler_once("foo", move |_params, _unsub| async move {
AndThen(
// These should be sent first..
vec![Json(1), Json(2), Json(3)],
// .. and then anything the channel is handing back.
rx
)
})
.build();
let rpc_client = RpcClient::new(rpc_client);
// Send a few values down the channel to be handed back in "foo" subscription:
tokio::spawn(async move {
for i in 4..=6 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
tx.send(Json(i)).await.unwrap();
}
});
// Expect all values back:
let sub = rpc_client.subscribe::<i32>("foo", rpc_params![], "unsub").await.unwrap();
let res: Vec<i32> = sub.map(|i| i.unwrap()).collect().await;
assert_eq!(res, vec![1,2,3,4,5,6]);
}
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
//! RPC types and client for interacting with a substrate node.
//!
//! An RPC client is instantiated and then used to create some methods, for instance
//! [`crate::methods::ChainHeadRpcMethods`], which defines the calls that can be made with it.
//! The core RPC client bits are:
//!
//! - [`RpcClientT`] is the underlying dynamic RPC implementation. This provides
//! the low level [`RpcClientT::request_raw`] and [`RpcClientT::subscribe_raw`]
//! methods.
//! - [`RpcClient`] is the higher level wrapper around this, offering
//! the [`RpcClient::request`] and [`RpcClient::subscribe`] methods.
//!
//! We then expose implementations here (depending on which features are enabled)
//! which implement [`RpcClientT`] and can therefore be used to construct [`RpcClient`]s.
//!
//! - **jsonrpsee**: Enable an RPC client based on `jsonrpsee`.
//! - **unstable-light-client**: Enable an RPC client which uses the Smoldot light client under
//! the hood to communicate with the network of choice.
//! - **reconnecting-rpc-client**: Enable an RPC client based on `jsonrpsee` which handles
//! reconnecting automatically in the event of network issues.
//! - **mock-rpc-client**: Enable a mock RPC client that can be used in tests.
//!
crate::macros::cfg_jsonrpsee! {
mod jsonrpsee_impl;
pub use jsonrpsee::core::client::Client as JsonrpseeRpcClient;
}
crate::macros::cfg_unstable_light_client! {
mod lightclient_impl;
pub use subxt_lightclient::LightClientRpc as LightClientRpcClient;
}
crate::macros::cfg_reconnecting_rpc_client! {
pub mod reconnecting_rpc_client;
pub use reconnecting_rpc_client::RpcClient as ReconnectingRpcClient;
}
crate::macros::cfg_mock_rpc_client! {
pub mod mock_rpc_client;
pub use mock_rpc_client::MockRpcClient;
}
mod rpc_client;
mod rpc_client_t;
pub use rpc_client::{rpc_params, RpcClient, RpcParams, RpcSubscription};
pub use rpc_client_t::{RawRpcFuture, RawRpcSubscription, RawValue, RpcClientT};
@@ -0,0 +1,632 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
//! # reconnecting-jsonrpsee-ws-client
//!
//! A simple reconnecting JSON-RPC WebSocket client for subxt which
//! automatically reconnects when the connection is lost but
//! it doesn't retain subscriptions and pending method calls when it reconnects.
//!
//! The logic which action to take for individual calls and subscriptions are
//! handled by the subxt backend implementations.
//!
mod platform;
#[cfg(test)]
mod tests;
mod utils;
use std::{
pin::Pin,
sync::Arc,
task::{self, Poll},
time::Duration,
};
use super::{RawRpcFuture, RawRpcSubscription, RpcClientT};
use crate::Error as SubxtRpcError;
use finito::Retry;
use futures::{FutureExt, Stream, StreamExt, TryStreamExt};
use jsonrpsee::core::{
client::{
Client as WsClient, ClientT, Subscription as RpcSubscription, SubscriptionClientT,
SubscriptionKind,
},
traits::ToRpcParams,
};
use platform::spawn;
use serde_json::value::RawValue;
use tokio::sync::{
mpsc::{self, UnboundedReceiver, UnboundedSender},
oneshot, Notify,
};
use url::Url;
use utils::display_close_reason;
// re-exports
pub use finito::{ExponentialBackoff, FibonacciBackoff, FixedInterval};
pub use jsonrpsee::core::client::IdKind;
pub use jsonrpsee::{core::client::error::Error as RpcError, rpc_params, types::SubscriptionId};
#[cfg(feature = "native")]
pub use jsonrpsee::ws_client::{HeaderMap, PingConfig};
const LOG_TARGET: &str = "subxt-reconnecting-rpc-client";
/// Method result.
pub type MethodResult = Result<Box<RawValue>, Error>;
/// Subscription result.
pub type SubscriptionResult = Result<Box<RawValue>, DisconnectedWillReconnect>;
/// The connection was closed, reconnect initiated and the subscription was dropped.
#[derive(Debug, thiserror::Error)]
#[error("The connection was closed because of `{0:?}` and reconnect initiated")]
pub struct DisconnectedWillReconnect(String);
/// New-type pattern which implements [`ToRpcParams`] that is required by jsonrpsee.
#[derive(Debug, Clone)]
struct RpcParams(Option<Box<RawValue>>);
impl ToRpcParams for RpcParams {
fn to_rpc_params(self) -> Result<Option<Box<RawValue>>, serde_json::Error> {
Ok(self.0)
}
}
#[derive(Debug)]
enum Op {
Call {
method: String,
params: RpcParams,
send_back: oneshot::Sender<MethodResult>,
},
Subscription {
subscribe_method: String,
params: RpcParams,
unsubscribe_method: String,
send_back: oneshot::Sender<Result<Subscription, Error>>,
},
}
/// Error that can occur when for a RPC call or subscription.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The client was dropped by the user.
#[error("The client was dropped")]
Dropped,
/// The connection was closed and reconnect initiated.
#[error(transparent)]
DisconnectedWillReconnect(#[from] DisconnectedWillReconnect),
/// Other rpc error.
#[error(transparent)]
RpcError(RpcError),
}
/// Represent a single subscription.
pub struct Subscription {
id: SubscriptionId<'static>,
stream: mpsc::UnboundedReceiver<SubscriptionResult>,
}
impl Subscription {
/// Returns the next notification from the stream.
/// This may return `None` if the subscription has been terminated,
/// which may happen if the channel becomes full or is dropped.
///
/// **Note:** This has an identical signature to the [`StreamExt::next`]
/// method (and delegates to that). Import [`StreamExt`] if you'd like
/// access to other stream combinator methods.
#[allow(clippy::should_implement_trait)]
pub async fn next(&mut self) -> Option<SubscriptionResult> {
StreamExt::next(self).await
}
/// Get the subscription ID.
pub fn id(&self) -> SubscriptionId<'static> {
self.id.clone()
}
}
impl Stream for Subscription {
type Item = SubscriptionResult;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut task::Context<'_>,
) -> task::Poll<Option<Self::Item>> {
match self.stream.poll_recv(cx) {
Poll::Ready(Some(msg)) => Poll::Ready(Some(msg)),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
impl std::fmt::Debug for Subscription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Subscription")
.field("id", &self.id)
.finish()
}
}
/// JSON-RPC client that reconnects automatically and may loose
/// subscription notifications when it reconnects.
#[derive(Clone, Debug)]
pub struct RpcClient {
tx: mpsc::UnboundedSender<Op>,
}
/// Builder for [`Client`].
#[derive(Clone, Debug)]
pub struct RpcClientBuilder<P> {
max_request_size: u32,
max_response_size: u32,
retry_policy: P,
#[cfg(feature = "native")]
ping_config: Option<PingConfig>,
#[cfg(feature = "native")]
// web doesn't support custom headers
// https://stackoverflow.com/a/4361358/6394734
headers: HeaderMap,
max_redirections: u32,
id_kind: IdKind,
max_log_len: u32,
max_concurrent_requests: u32,
request_timeout: Duration,
connection_timeout: Duration,
}
impl Default for RpcClientBuilder<ExponentialBackoff> {
fn default() -> Self {
Self {
max_request_size: 10 * 1024 * 1024,
max_response_size: 10 * 1024 * 1024,
retry_policy: ExponentialBackoff::from_millis(10).max_delay(Duration::from_secs(60)),
#[cfg(feature = "native")]
ping_config: Some(PingConfig::new()),
#[cfg(feature = "native")]
headers: HeaderMap::new(),
max_redirections: 5,
id_kind: IdKind::Number,
max_log_len: 1024,
max_concurrent_requests: 1024,
request_timeout: Duration::from_secs(60),
connection_timeout: Duration::from_secs(10),
}
}
}
impl RpcClientBuilder<ExponentialBackoff> {
/// Create a new builder.
pub fn new() -> Self {
Self::default()
}
}
impl<P> RpcClientBuilder<P>
where
P: Iterator<Item = Duration> + Send + Sync + 'static + Clone,
{
/// Configure the min response size a for websocket message.
///
/// Default: 10MB
pub fn max_request_size(mut self, max: u32) -> Self {
self.max_request_size = max;
self
}
/// Configure the max response size a for websocket message.
///
/// Default: 10MB
pub fn max_response_size(mut self, max: u32) -> Self {
self.max_response_size = max;
self
}
/// Set the max number of redirections to perform until a connection is regarded as failed.
///
/// Default: 5
pub fn max_redirections(mut self, redirect: u32) -> Self {
self.max_redirections = redirect;
self
}
/// Configure how many concurrent method calls are allowed.
///
/// Default: 1024
pub fn max_concurrent_requests(mut self, max: u32) -> Self {
self.max_concurrent_requests = max;
self
}
/// Configure how long until a method call is regarded as failed.
///
/// Default: 1 minute
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
/// Set connection timeout for the WebSocket handshake
///
/// Default: 10 seconds
pub fn connection_timeout(mut self, timeout: Duration) -> Self {
self.connection_timeout = timeout;
self
}
/// Configure the data type of the request object ID
///
/// Default: number
pub fn id_format(mut self, kind: IdKind) -> Self {
self.id_kind = kind;
self
}
/// Set maximum length for logging calls and responses.
/// Logs bigger than this limit will be truncated.
///
/// Default: 1024
pub fn set_max_logging_length(mut self, max: u32) -> Self {
self.max_log_len = max;
self
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
/// Configure custom headers to use in the WebSocket handshake.
pub fn set_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
/// Configure which retry policy to use when a connection is lost.
///
/// Default: Exponential backoff 10ms
pub fn retry_policy<T>(self, retry_policy: T) -> RpcClientBuilder<T> {
RpcClientBuilder {
max_request_size: self.max_request_size,
max_response_size: self.max_response_size,
retry_policy,
#[cfg(feature = "native")]
ping_config: self.ping_config,
#[cfg(feature = "native")]
headers: self.headers,
max_redirections: self.max_redirections,
max_log_len: self.max_log_len,
id_kind: self.id_kind,
max_concurrent_requests: self.max_concurrent_requests,
request_timeout: self.request_timeout,
connection_timeout: self.connection_timeout,
}
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
/// Configure the WebSocket ping/pong interval.
///
/// Default: 30 seconds.
pub fn enable_ws_ping(mut self, ping_config: PingConfig) -> Self {
self.ping_config = Some(ping_config);
self
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
/// Disable WebSocket ping/pongs.
///
/// Default: 30 seconds.
pub fn disable_ws_ping(mut self) -> Self {
self.ping_config = None;
self
}
/// Build and connect to the target.
pub async fn build(self, url: impl AsRef<str>) -> Result<RpcClient, RpcError> {
let url = Url::parse(url.as_ref()).map_err(|e| RpcError::Transport(Box::new(e)))?;
let (tx, rx) = mpsc::unbounded_channel();
let client = Retry::new(self.retry_policy.clone(), || {
platform::ws_client(&url, &self)
})
.await?;
platform::spawn(background_task(client, rx, url, self));
Ok(RpcClient { tx })
}
}
impl RpcClient {
/// Create a builder.
pub fn builder() -> RpcClientBuilder<ExponentialBackoff> {
RpcClientBuilder::new()
}
/// Perform a JSON-RPC method call.
pub async fn request(
&self,
method: String,
params: Option<Box<RawValue>>,
) -> Result<Box<RawValue>, Error> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Op::Call {
method,
params: RpcParams(params),
send_back: tx,
})
.map_err(|_| Error::Dropped)?;
rx.await.map_err(|_| Error::Dropped)?
}
/// Perform a JSON-RPC subscription.
pub async fn subscribe(
&self,
subscribe_method: String,
params: Option<Box<RawValue>>,
unsubscribe_method: String,
) -> Result<Subscription, Error> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Op::Subscription {
subscribe_method,
params: RpcParams(params),
unsubscribe_method,
send_back: tx,
})
.map_err(|_| Error::Dropped)?;
rx.await.map_err(|_| Error::Dropped)?
}
}
impl RpcClientT for RpcClient {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>> {
async {
self.request(method.to_string(), params)
.await
.map_err(error_to_rpc_error)
}
.boxed()
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
async {
let sub = self
.subscribe(sub.to_string(), params, unsub.to_string())
.await
.map_err(error_to_rpc_error)?;
let id = match sub.id() {
SubscriptionId::Num(n) => n.to_string(),
SubscriptionId::Str(s) => s.to_string(),
};
let stream = sub
// NOTE: The stream emits only one error `DisconnectWillReconnect if the connection was lost
// and safe to wrap it in a `SubxtRpcError::DisconnectWillReconnect` here
.map_err(|e: DisconnectedWillReconnect| {
SubxtRpcError::DisconnectedWillReconnect(e.to_string())
})
.boxed();
Ok(RawRpcSubscription {
stream,
id: Some(id),
})
}
.boxed()
}
}
/// Convert a reconnecting client Error into the RPC error in this crate.
/// The main reason for this is to capture user errors so that
/// they can be represented/handled without casting.
fn error_to_rpc_error(error: Error) -> SubxtRpcError {
match error {
Error::DisconnectedWillReconnect(reason) => {
SubxtRpcError::DisconnectedWillReconnect(reason.to_string())
},
Error::RpcError(RpcError::Call(e)) => {
SubxtRpcError::User(crate::UserError {
code: e.code(),
message: e.message().to_owned(),
data: e.data().map(|d| d.to_owned())
})
},
e => {
SubxtRpcError::Client(Box::new(e))
}
}
}
async fn background_task<P>(
mut client: Arc<WsClient>,
mut rx: UnboundedReceiver<Op>,
url: Url,
client_builder: RpcClientBuilder<P>,
) where
P: Iterator<Item = Duration> + Send + 'static + Clone,
{
let disconnect = Arc::new(tokio::sync::Notify::new());
loop {
tokio::select! {
// An incoming JSON-RPC call to dispatch.
next_message = rx.recv() => {
match next_message {
None => break,
Some(op) => {
spawn(dispatch_call(client.clone(), op, disconnect.clone()));
}
};
}
// The connection was terminated and try to reconnect.
_ = client.on_disconnect() => {
let params = ReconnectParams {
url: &url,
client_builder: &client_builder,
close_reason: client.disconnect_reason().await,
};
client = match reconnect(params).await {
Ok(client) => client,
Err(e) => {
tracing::debug!(target: LOG_TARGET, "Failed to reconnect: {e}; terminating the connection");
break;
}
};
}
}
}
disconnect.notify_waiters();
}
async fn dispatch_call(client: Arc<WsClient>, op: Op, on_disconnect: Arc<tokio::sync::Notify>) {
match op {
Op::Call {
method,
params,
send_back,
} => {
match client.request::<Box<RawValue>, _>(&method, params).await {
Ok(rp) => {
// Fails only if the request is dropped by the client.
let _ = send_back.send(Ok(rp));
}
Err(RpcError::RestartNeeded(e)) => {
// Fails only if the request is dropped by the client.
let _ = send_back.send(Err(DisconnectedWillReconnect(e.to_string()).into()));
}
Err(e) => {
// Fails only if the request is dropped by the client.
let _ = send_back.send(Err(Error::RpcError(e)));
}
}
}
Op::Subscription {
subscribe_method,
params,
unsubscribe_method,
send_back,
} => {
match client
.subscribe::<Box<RawValue>, _>(
&subscribe_method,
params.clone(),
&unsubscribe_method,
)
.await
{
Ok(sub) => {
let (tx, rx) = mpsc::unbounded_channel();
let sub_id = match sub.kind() {
SubscriptionKind::Subscription(id) => id.clone().into_owned(),
_ => unreachable!("No method subscriptions possible in this crate; qed"),
};
platform::spawn(subscription_handler(
tx.clone(),
sub,
on_disconnect.clone(),
client.clone(),
));
let stream = Subscription {
id: sub_id,
stream: rx,
};
// Fails only if the request is dropped by the client.
let _ = send_back.send(Ok(stream));
}
Err(RpcError::RestartNeeded(e)) => {
// Fails only if the request is dropped by the client.
let _ = send_back.send(Err(DisconnectedWillReconnect(e.to_string()).into()));
}
Err(e) => {
// Fails only if the request is dropped.
let _ = send_back.send(Err(Error::RpcError(e)));
}
}
}
}
}
/// Handler for each individual subscription.
async fn subscription_handler(
sub_tx: UnboundedSender<SubscriptionResult>,
mut rpc_sub: RpcSubscription<Box<RawValue>>,
client_closed: Arc<Notify>,
client: Arc<WsClient>,
) {
loop {
tokio::select! {
next_msg = rpc_sub.next() => {
let Some(notif) = next_msg else {
let close = client.disconnect_reason().await;
_ = sub_tx.send(Err(DisconnectedWillReconnect(close.to_string())));
break;
};
let msg = notif.expect("RawValue is valid JSON; qed");
// Fails only if subscription was closed by the user.
if sub_tx.send(Ok(msg)).is_err() {
break;
}
}
// This channel indices whether the subscription was closed by user.
_ = sub_tx.closed() => {
break;
}
// This channel indicates whether the main task has been closed.
// at this point no further messages are processed.
_ = client_closed.notified() => {
break;
}
}
}
}
struct ReconnectParams<'a, P> {
url: &'a Url,
client_builder: &'a RpcClientBuilder<P>,
close_reason: RpcError,
}
async fn reconnect<P>(params: ReconnectParams<'_, P>) -> Result<Arc<WsClient>, RpcError>
where
P: Iterator<Item = Duration> + Send + 'static + Clone,
{
let ReconnectParams {
url,
client_builder,
close_reason,
} = params;
let retry_policy = client_builder.retry_policy.clone();
tracing::debug!(target: LOG_TARGET, "Connection to {url} was closed: `{}`; starting to reconnect", display_close_reason(&close_reason));
let client = Retry::new(retry_policy.clone(), || {
platform::ws_client(url, client_builder)
})
.await?;
tracing::debug!(target: LOG_TARGET, "Connection to {url} was successfully re-established");
Ok(client)
}
@@ -0,0 +1,84 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use super::{RpcClientBuilder, RpcError};
use jsonrpsee::core::client::Client;
use std::sync::Arc;
use url::Url;
#[cfg(feature = "native")]
pub use tokio::spawn;
#[cfg(feature = "web")]
pub use wasm_bindgen_futures::spawn_local as spawn;
#[cfg(feature = "native")]
pub async fn ws_client<P>(
url: &Url,
builder: &RpcClientBuilder<P>,
) -> Result<Arc<Client>, RpcError> {
use jsonrpsee::ws_client::WsClientBuilder;
let RpcClientBuilder {
max_request_size,
max_response_size,
ping_config,
headers,
max_redirections,
id_kind,
max_concurrent_requests,
max_log_len,
request_timeout,
connection_timeout,
..
} = builder;
let mut ws_client_builder = WsClientBuilder::new()
.max_request_size(*max_request_size)
.max_response_size(*max_response_size)
.set_headers(headers.clone())
.max_redirections(*max_redirections as usize)
.max_buffer_capacity_per_subscription(tokio::sync::Semaphore::MAX_PERMITS)
.max_concurrent_requests(*max_concurrent_requests as usize)
.set_max_logging_length(*max_log_len)
.set_tcp_no_delay(true)
.request_timeout(*request_timeout)
.connection_timeout(*connection_timeout)
.id_format(*id_kind);
if let Some(ping) = ping_config {
ws_client_builder = ws_client_builder.enable_ws_ping(*ping);
}
let client = ws_client_builder.build(url.as_str()).await?;
Ok(Arc::new(client))
}
#[cfg(feature = "web")]
pub async fn ws_client<P>(
url: &Url,
builder: &RpcClientBuilder<P>,
) -> Result<Arc<Client>, RpcError> {
use jsonrpsee::wasm_client::WasmClientBuilder;
let RpcClientBuilder {
id_kind,
max_concurrent_requests,
max_log_len,
request_timeout,
..
} = builder;
let ws_client_builder = WasmClientBuilder::new()
.max_buffer_capacity_per_subscription(tokio::sync::Semaphore::MAX_PERMITS)
.max_concurrent_requests(*max_concurrent_requests as usize)
.set_max_logging_length(*max_log_len)
.request_timeout(*request_timeout)
.id_format(*id_kind);
let client = ws_client_builder.build(url.as_str()).await?;
Ok(Arc::new(client))
}
@@ -0,0 +1,270 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use super::*;
use futures::{future::Either, FutureExt};
use jsonrpsee::core::BoxError;
use jsonrpsee::server::{
http, stop_channel, ws, ConnectionGuard, ConnectionState, HttpRequest, HttpResponse, RpcModule,
RpcServiceBuilder, ServerConfig, SubscriptionMessage,
};
#[tokio::test]
async fn call_works() {
let (_handle, addr) = run_server().await.unwrap();
let client = RpcClient::builder().build(addr).await.unwrap();
assert!(client.request("say_hello".to_string(), None).await.is_ok(),)
}
#[tokio::test]
async fn sub_works() {
let (_handle, addr) = run_server().await.unwrap();
let client = RpcClient::builder()
.retry_policy(ExponentialBackoff::from_millis(50))
.build(addr)
.await
.unwrap();
let mut sub = client
.subscribe(
"subscribe_lo".to_string(),
None,
"unsubscribe_lo".to_string(),
)
.await
.unwrap();
assert!(sub.next().await.is_some());
}
#[tokio::test]
async fn sub_with_reconnect() {
let (handle, addr) = run_server().await.unwrap();
let client = RpcClient::builder().build(addr.clone()).await.unwrap();
let mut sub = client
.subscribe(
"subscribe_lo".to_string(),
None,
"unsubscribe_lo".to_string(),
)
.await
.unwrap();
let _ = handle.send(());
// Hack to wait for the server to restart.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(matches!(sub.next().await, Some(Ok(_))));
assert!(matches!(
sub.next().await,
Some(Err(DisconnectedWillReconnect(_)))
));
// Restart the server.
let (_handle, _) = run_server_with_settings(Some(&addr), false).await.unwrap();
// Hack to wait for the server to restart.
tokio::time::sleep(Duration::from_millis(100)).await;
// Subscription should work after reconnect.
let mut sub = client
.subscribe(
"subscribe_lo".to_string(),
None,
"unsubscribe_lo".to_string(),
)
.await
.unwrap();
assert!(matches!(sub.next().await, Some(Ok(_))));
}
#[tokio::test]
async fn call_with_reconnect() {
let (handle, addr) = run_server_with_settings(None, true).await.unwrap();
let client = Arc::new(RpcClient::builder().build(addr.clone()).await.unwrap());
let req_fut = client.request("say_hello".to_string(), None).boxed();
let timeout_fut = tokio::time::sleep(Duration::from_secs(5));
// If the call isn't replied in 5 secs then it's regarded as it's still pending.
let req_fut = match futures::future::select(Box::pin(timeout_fut), req_fut).await {
Either::Left((_, f)) => f,
Either::Right(_) => panic!("RPC call finished"),
};
// Close the connection with a pending call.
let _ = handle.send(());
// Restart the server
let (_handle, _) = run_server_with_settings(Some(&addr), false).await.unwrap();
// Hack to wait for the server to restart.
tokio::time::sleep(Duration::from_millis(100)).await;
// This call should fail because reconnect.
assert!(req_fut.await.is_err());
// Future call should work after reconnect.
assert!(client.request("say_hello".to_string(), None).await.is_ok());
}
async fn run_server() -> Result<(tokio::sync::broadcast::Sender<()>, String), BoxError> {
run_server_with_settings(None, false).await
}
async fn run_server_with_settings(
url: Option<&str>,
dont_respond_to_method_calls: bool,
) -> Result<(tokio::sync::broadcast::Sender<()>, String), BoxError> {
use jsonrpsee::server::HttpRequest;
let sockaddr = match url {
Some(url) => url.strip_prefix("ws://").unwrap(),
None => "127.0.0.1:0",
};
let mut i = 0;
let listener = loop {
if let Ok(l) = tokio::net::TcpListener::bind(sockaddr).await {
break l;
}
tokio::time::sleep(Duration::from_millis(100)).await;
if i >= 10 {
panic!("Addr already in use");
}
i += 1;
};
let mut module = RpcModule::new(());
if dont_respond_to_method_calls {
module.register_async_method("say_hello", |_, _, _| async {
futures::future::pending::<()>().await;
"timeout"
})?;
} else {
module.register_async_method("say_hello", |_, _, _| async { "lo" })?;
}
module.register_subscription(
"subscribe_lo",
"subscribe_lo",
"unsubscribe_lo",
|_params, pending, _ctx, _| async move {
let sink = pending.accept().await.unwrap();
let i = 0;
loop {
if sink
.send(SubscriptionMessage::from_json(&i).unwrap())
.await
.is_err()
{
break;
}
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
}
},
)?;
let (tx, mut rx) = tokio::sync::broadcast::channel(4);
let tx2 = tx.clone();
let (stop_handle, server_handle) = stop_channel();
let addr = listener.local_addr().expect("Could not find local addr");
tokio::spawn(async move {
loop {
let sock = tokio::select! {
res = listener.accept() => {
match res {
Ok((stream, _remote_addr)) => stream,
Err(e) => {
tracing::error!("Failed to accept connection: {:?}", e);
continue;
}
}
}
_ = rx.recv() => {
break
}
};
let module = module.clone();
let rx2 = tx2.subscribe();
let tx2 = tx2.clone();
let stop_handle2 = stop_handle.clone();
let svc = tower::service_fn(move |req: HttpRequest<hyper::body::Incoming>| {
let module = module.clone();
let tx = tx2.clone();
let stop_handle = stop_handle2.clone();
let conn_permit = ConnectionGuard::new(1).try_acquire().unwrap();
if ws::is_upgrade_request(&req) {
let rpc_service = RpcServiceBuilder::new();
let conn = ConnectionState::new(stop_handle, 1, conn_permit);
async move {
let mut rx = tx.subscribe();
let (rp, conn_fut) =
ws::connect(req, ServerConfig::default(), module, conn, rpc_service)
.await
.unwrap();
tokio::spawn(async move {
tokio::select! {
_ = conn_fut => (),
_ = rx.recv() => {},
}
});
Ok::<_, BoxError>(rp)
}
.boxed()
} else {
async { Ok(http::response::denied()) }.boxed()
}
});
tokio::spawn(serve_with_graceful_shutdown(sock, svc, rx2));
}
drop(server_handle);
});
Ok((tx, format!("ws://{}", addr)))
}
async fn serve_with_graceful_shutdown<S, B, I>(
io: I,
service: S,
mut rx: tokio::sync::broadcast::Receiver<()>,
) where
S: tower::Service<HttpRequest<hyper::body::Incoming>, Response = HttpResponse<B>>
+ Clone
+ Send
+ 'static,
S::Future: Send,
S::Response: Send,
S::Error: Into<BoxError>,
B: http_body::Body<Data = hyper::body::Bytes> + Send + 'static,
B::Error: Into<BoxError>,
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
{
if let Err(e) =
jsonrpsee::server::serve_with_graceful_shutdown(io, service, rx.recv().map(|_| ())).await
{
tracing::error!("Error while serving: {:?}", e);
}
}
@@ -0,0 +1,14 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
//! Utils.
use super::RpcError;
pub fn display_close_reason(err: &RpcError) -> String {
match err {
RpcError::RestartNeeded(e) => e.to_string(),
other => other.to_string(),
}
}
+292
View File
@@ -0,0 +1,292 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use super::{RawRpcSubscription, RpcClientT};
use crate::Error;
use futures::{Stream, StreamExt};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::value::RawValue;
use std::{pin::Pin, sync::Arc, task::Poll};
/// A concrete wrapper around an [`RpcClientT`] which provides some higher level helper methods
/// and is cheaply cloneable.
#[derive(Clone)]
pub struct RpcClient {
client: Arc<dyn RpcClientT>,
}
impl RpcClient {
#[cfg(feature = "jsonrpsee")]
#[cfg_attr(docsrs, doc(cfg(feature = "jsonrpsee")))]
/// Create a default RPC client pointed at some URL, currently based on [`jsonrpsee`].
///
/// Errors if an insecure URL is provided. In this case, use [`RpcClient::from_insecure_url`] instead.
pub async fn from_url<U: AsRef<str>>(url: U) -> Result<Self, Error> {
crate::utils::validate_url_is_secure(url.as_ref())?;
RpcClient::from_insecure_url(url).await
}
#[cfg(feature = "jsonrpsee")]
/// Create a default RPC client pointed at some URL, currently based on [`jsonrpsee`].
///
/// Allows insecure URLs without SSL encryption, e.g. (http:// and ws:// URLs).
pub async fn from_insecure_url<U: AsRef<str>>(url: U) -> Result<Self, Error> {
let client = jsonrpsee_helpers::client(url.as_ref())
.await
.map_err(|e| Error::Client(Box::new(e)))?;
Ok(Self::new(client))
}
/// Create a new [`RpcClient`] from an arbitrary [`RpcClientT`] implementation.
pub fn new<R: RpcClientT>(client: R) -> Self {
RpcClient {
client: Arc::new(client),
}
}
/// Make an RPC request, given a method name and some parameters.
///
/// See [`RpcParams`] and the [`rpc_params!`] macro for an example of how to
/// construct the parameters.
pub async fn request<Res: DeserializeOwned>(
&self,
method: &str,
params: RpcParams,
) -> Result<Res, Error> {
let res = self.client.request_raw(method, params.build()).await?;
let val = serde_json::from_str(res.get()).map_err(Error::Deserialization)?;
Ok(val)
}
/// Subscribe to an RPC endpoint, providing the parameters and the method to call to
/// unsubscribe from it again.
///
/// See [`RpcParams`] and the [`rpc_params!`] macro for an example of how to
/// construct the parameters.
pub async fn subscribe<Res: DeserializeOwned>(
&self,
sub: &str,
params: RpcParams,
unsub: &str,
) -> Result<RpcSubscription<Res>, Error> {
let sub = self
.client
.subscribe_raw(sub, params.build(), unsub)
.await?;
Ok(RpcSubscription::new(sub))
}
}
impl<C: RpcClientT> From<C> for RpcClient {
fn from(client: C) -> Self {
RpcClient::new(client)
}
}
impl std::fmt::Debug for RpcClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("RpcClient").finish()
}
}
impl std::ops::Deref for RpcClient {
type Target = dyn RpcClientT;
fn deref(&self) -> &Self::Target {
&*self.client
}
}
/// Create some [`RpcParams`] to pass to our [`RpcClient`]. [`RpcParams`]
/// simply enforces that parameters handed to our [`RpcClient`] methods
/// are the correct shape.
///
/// As with the [`serde_json::json!`] macro, this will panic if you provide
/// parameters which cannot successfully be serialized to JSON.
///
/// # Example
///
/// ```rust
/// use subxt_rpcs::client::{ rpc_params, RpcParams };
///
/// // If you provide no params you get `None` back
/// let params: RpcParams = rpc_params![];
/// assert!(params.build().is_none());
///
/// // If you provide params you get `Some<Box<RawValue>>` back.
/// let params: RpcParams = rpc_params![1, true, "foo"];
/// assert_eq!(params.build().unwrap().get(), "[1,true,\"foo\"]");
/// ```
#[macro_export]
macro_rules! rpc_params {
($($p:expr), *) => {{
// May be unused if empty; no params.
#[allow(unused_mut)]
let mut params = $crate::client::RpcParams::new();
$(
params.push($p).expect("values passed to rpc_params! must be serializable to JSON");
)*
params
}}
}
pub use rpc_params;
/// This represents the parameters passed to an [`RpcClient`], and exists to
/// enforce that parameters are provided in the correct format.
///
/// Prefer to use the [`rpc_params!`] macro for simpler creation of these.
///
/// # Example
///
/// ```rust
/// use subxt_rpcs::client::RpcParams;
///
/// let mut params = RpcParams::new();
/// params.push(1).unwrap();
/// params.push(true).unwrap();
/// params.push("foo").unwrap();
///
/// assert_eq!(params.build().unwrap().get(), "[1,true,\"foo\"]");
/// ```
#[derive(Debug, Clone, Default)]
pub struct RpcParams(Vec<u8>);
impl RpcParams {
/// Create a new empty set of [`RpcParams`].
pub fn new() -> Self {
Self(Vec::new())
}
/// Push a parameter into our [`RpcParams`]. This serializes it to JSON
/// in the process, and so will return an error if this is not possible.
pub fn push<P: Serialize>(&mut self, param: P) -> Result<(), Error> {
if self.0.is_empty() {
self.0.push(b'[');
} else {
self.0.push(b',')
}
serde_json::to_writer(&mut self.0, &param).map_err(Error::Deserialization)?;
Ok(())
}
/// Build a [`RawValue`] from our params, returning `None` if no parameters
/// were provided.
pub fn build(mut self) -> Option<Box<RawValue>> {
if self.0.is_empty() {
None
} else {
self.0.push(b']');
let s = unsafe { String::from_utf8_unchecked(self.0) };
Some(RawValue::from_string(s).expect("Should be valid JSON"))
}
}
}
/// A generic RPC Subscription. This implements [`Stream`], and so most of
/// the functionality you'll need to interact with it comes from the
/// [`StreamExt`] extension trait.
pub struct RpcSubscription<Res> {
inner: RawRpcSubscription,
_marker: std::marker::PhantomData<Res>,
}
impl<Res> std::fmt::Debug for RpcSubscription<Res> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RpcSubscription")
.field("inner", &"RawRpcSubscription")
.field("_marker", &self._marker)
.finish()
}
}
impl<Res> RpcSubscription<Res> {
/// Creates a new [`RpcSubscription`].
pub fn new(inner: RawRpcSubscription) -> Self {
Self {
inner,
_marker: std::marker::PhantomData,
}
}
/// Obtain the ID associated with this subscription.
pub fn subscription_id(&self) -> Option<&str> {
self.inner.id.as_deref()
}
}
impl<Res: DeserializeOwned> RpcSubscription<Res> {
/// Returns the next item in the stream. This is just a wrapper around
/// [`StreamExt::next()`] so that you can avoid the extra import.
pub async fn next(&mut self) -> Option<Result<Res, Error>> {
StreamExt::next(self).await
}
}
impl<Res> std::marker::Unpin for RpcSubscription<Res> {}
impl<Res: DeserializeOwned> Stream for RpcSubscription<Res> {
type Item = Result<Res, Error>;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
let res = futures::ready!(self.inner.stream.poll_next_unpin(cx));
// Decode the inner RawValue to the type we're expecting and map
// any errors to the right shape:
let res = res.map(|r| {
r.and_then(|raw_val| {
serde_json::from_str(raw_val.get()).map_err(Error::Deserialization)
})
});
Poll::Ready(res)
}
}
// helpers for a jsonrpsee specific RPC client.
#[cfg(all(feature = "jsonrpsee", feature = "native"))]
mod jsonrpsee_helpers {
pub use jsonrpsee::{
client_transport::ws::{self, EitherStream, Url, WsTransportClientBuilder},
core::client::{Client, Error},
};
use tokio_util::compat::Compat;
pub type Sender = ws::Sender<Compat<EitherStream>>;
pub type Receiver = ws::Receiver<Compat<EitherStream>>;
/// Build WS RPC client from URL
pub async fn client(url: &str) -> Result<Client, Error> {
let (sender, receiver) = ws_transport(url).await?;
Ok(Client::builder()
.max_buffer_capacity_per_subscription(4096)
.build_with_tokio(sender, receiver))
}
async fn ws_transport(url: &str) -> Result<(Sender, Receiver), Error> {
let url = Url::parse(url).map_err(|e| Error::Transport(e.into()))?;
WsTransportClientBuilder::default()
.build(url)
.await
.map_err(|e| Error::Transport(e.into()))
}
}
// helpers for a jsonrpsee specific RPC client.
#[cfg(all(feature = "jsonrpsee", feature = "web", target_arch = "wasm32"))]
mod jsonrpsee_helpers {
pub use jsonrpsee::{
client_transport::web,
core::client::{Client, ClientBuilder, Error},
};
/// Build web RPC client from URL
pub async fn client(url: &str) -> Result<Client, Error> {
let (sender, receiver) = web::connect(url)
.await
.map_err(|e| Error::Transport(e.into()))?;
Ok(ClientBuilder::default()
.max_buffer_capacity_per_subscription(4096)
.build_with_wasm(sender, receiver))
}
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2019-2025 Parity Technologies (UK) Ltd.
// This file is dual-licensed as Apache-2.0 or GPL-3.0.
// see LICENSE for license details.
use crate::Error;
use futures::Stream;
use std::{future::Future, pin::Pin};
// Re-exporting for simplicity since it's used a bunch in the trait definition.
pub use serde_json::value::RawValue;
/// A trait describing low level JSON-RPC interactions. Implementations of this can be
/// used to instantiate a [`super::RpcClient`], used for lower level RPC calls via eg
/// [`crate::methods::LegacyRpcMethods`] and [`crate::methods::ChainHeadRpcMethods`].
///
/// This is a low level interface whose methods expect an already-serialized set of params,
/// and return an owned but still-serialized [`RawValue`], deferring deserialization to
/// the caller. This is the case because we want the methods to be object-safe (which prohibits
/// generics), and want to avoid any unnecessary allocations in serializing/deserializing
/// parameters.
///
/// # Panics
///
/// Implementations are free to panic if the `RawValue`'s passed to `request_raw` or
/// `subscribe_raw` are not JSON arrays. Internally, we ensure that this is always the case.
pub trait RpcClientT: Send + Sync + 'static {
/// Make a raw request for which we expect a single response back from. Implementations
/// should expect that the params will either be `None`, or be an already-serialized
/// JSON array of parameters.
///
/// See [`super::RpcParams`] and the [`super::rpc_params!`] macro for an example of how to
/// construct the parameters.
///
/// Prefer to use the interface provided on [`super::RpcClient`] where possible.
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>>;
/// Subscribe to some method. Implementations should expect that the params will
/// either be `None`, or be an already-serialized JSON array of parameters.
///
/// See [`super::RpcParams`] and the [`super::rpc_params!`] macro for an example of how to
/// construct the parameters.
///
/// Prefer to use the interface provided on [`super::RpcClient`] where possible.
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription>;
}
/// A boxed future that is returned from the [`RpcClientT`] methods.
pub type RawRpcFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + Send + 'a>>;
/// The RPC subscription returned from [`RpcClientT`]'s `subscription` method.
pub struct RawRpcSubscription {
/// The subscription stream.
pub stream: Pin<Box<dyn Stream<Item = Result<Box<RawValue>, Error>> + Send + 'static>>,
/// The ID associated with the subscription.
pub id: Option<String>,
}
impl<T: RpcClientT> RpcClientT for std::sync::Arc<T> {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>> {
(**self).request_raw(method, params)
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
(**self).subscribe_raw(sub, params, unsub)
}
}
impl<T: RpcClientT> RpcClientT for Box<T> {
fn request_raw<'a>(
&'a self,
method: &'a str,
params: Option<Box<RawValue>>,
) -> RawRpcFuture<'a, Box<RawValue>> {
(**self).request_raw(method, params)
}
fn subscribe_raw<'a>(
&'a self,
sub: &'a str,
params: Option<Box<RawValue>>,
unsub: &'a str,
) -> RawRpcFuture<'a, RawRpcSubscription> {
(**self).subscribe_raw(sub, params, unsub)
}
}