Files
khadhroony-bobot/khbb_lib/src/solana_rpc_http.rs
2026-04-18 11:03:41 +02:00

408 lines
14 KiB
Rust

// file: khbb_lib/src/solana_rpc_http.rs
//! Minimal Solana HTTP JSON-RPC client.
//!
//! This module intentionally avoids the high-level Solana RPC client and uses
//! plain `reqwest` with explicit JSON-RPC request and response handling.
/// Configuration of the minimal Solana HTTP RPC client.
#[derive(Debug, Clone)]
pub struct KhbbSolanaHttpRpcClientConfig {
/// Base HTTP RPC endpoint.
pub url: std::string::String,
}
/// Minimal JSON-RPC request envelope.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(bound(
serialize = "TParams: serde::Serialize",
deserialize = "TParams: serde::de::DeserializeOwned"
))]
pub struct KhbbJsonRpcRequestEnvelope<TParams>
where
TParams: serde::Serialize,
{
/// JSON-RPC protocol version.
pub jsonrpc: std::string::String,
/// RPC method name.
pub method: std::string::String,
/// RPC parameters.
pub params: TParams,
/// Request identifier.
pub id: u64,
}
/// Minimal JSON-RPC response envelope.
///
/// The Solana-specific result payload should use official types from
/// `solana-rpc-client-api::response` whenever they exist.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(bound(
serialize = "TResult: serde::Serialize",
deserialize = "TResult: serde::de::DeserializeOwned"
))]
pub struct KhbbJsonRpcResponseEnvelope<TResult>
where
TResult: serde::Serialize + serde::de::DeserializeOwned,
{
/// JSON-RPC protocol version.
pub jsonrpc: std::string::String,
/// Successful result if present.
pub result: std::option::Option<TResult>,
/// Raw error object if present.
pub error: std::option::Option<serde_json::Value>,
/// Response identifier.
pub id: serde_json::Value,
}
/// HTTP RPC response bundle containing both raw and parsed payloads.
pub struct KhbbHttpRpcCallOutput<TResult>
where
TResult: serde::Serialize + serde::de::DeserializeOwned,
{
/// Request identifier.
pub request_id: u64,
/// Request method.
pub method: std::string::String,
/// Serialized request body sent to the server.
pub request_body: std::string::String,
/// Raw response body received from the server.
pub response_body: std::string::String,
/// Parsed JSON-RPC response.
pub response: KhbbJsonRpcResponseEnvelope<TResult>,
}
/// Minimal Solana HTTP JSON-RPC client.
#[derive(Debug, Clone)]
pub struct KhbbSolanaHttpRpcClient {
/// Request client.
pub(crate) http_client: reqwest::Client,
/// Client configuration.
pub(crate) config: KhbbSolanaHttpRpcClientConfig,
}
impl KhbbSolanaHttpRpcClient {
/// Creates a new minimal Solana HTTP JSON-RPC client.
pub fn new(
config: KhbbSolanaHttpRpcClientConfig,
) -> core::result::Result<Self, crate::KhbbError> {
if config.url.trim().is_empty() {
return Err(crate::KhbbError::Config {
message: std::string::String::from("solana http rpc client url must not be empty"),
});
}
let builder = reqwest::Client::builder();
let build_result = builder.build();
let http_client = match build_result {
Ok(value) => value,
Err(error) => {
return Err(crate::KhbbError::Runtime {
context: "build reqwest client",
message: error.to_string(),
});
},
};
Ok(Self { http_client, config })
}
/// Sends a generic JSON-RPC request.
pub async fn send_json_rpc_request<TResult, TParams>(
&self,
rpc_request: solana_rpc_client_api::request::RpcRequest,
params: TParams,
id: u64,
) -> core::result::Result<KhbbHttpRpcCallOutput<TResult>, crate::KhbbError>
where
TResult: serde::Serialize + serde::de::DeserializeOwned,
TParams: serde::Serialize,
{
let request_result = build_json_rpc_request(rpc_request, params, id);
let method_name_result = rpc_request_method_name(rpc_request);
let method_name = match method_name_result {
Ok(value) => value,
Err(error) => {
return Err(error);
},
};
let request = match request_result {
Ok(value) => value,
Err(error) => {
return Err(error);
},
};
let request_body_result = serde_json::to_string(&request);
let request_body = match request_body_result {
Ok(value) => value,
Err(error) => {
return Err(crate::KhbbError::Json {
context: "serialize json rpc request",
message: error.to_string(),
});
},
};
let send_result = self
.http_client
.post(&self.config.url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(request_body.clone())
.send()
.await;
let response = match send_result {
Ok(value) => value,
Err(error) => {
return Err(crate::KhbbError::Runtime {
context: "send json rpc http request",
message: error.to_string(),
});
},
};
let response_text_result = response.text().await;
let response_body = match response_text_result {
Ok(value) => value,
Err(error) => {
return Err(crate::KhbbError::Runtime {
context: "read json rpc http response body",
message: error.to_string(),
});
},
};
let parse_result = parse_json_rpc_response::<TResult>(&response_body);
let parsed_response = match parse_result {
Ok(value) => value,
Err(error) => {
return Err(error);
},
};
if parsed_response.error.is_some() {
return Err(crate::KhbbError::Runtime {
context: "json rpc server returned error",
message: response_body.clone(),
});
}
Ok(KhbbHttpRpcCallOutput {
request_id: id,
method: std::string::String::from(method_name),
request_body,
response_body,
response: parsed_response,
})
}
/// Calls the `getHealth` RPC method.
pub async fn get_health(
&self,
id: u64,
) -> core::result::Result<KhbbHttpRpcCallOutput<std::string::String>, crate::KhbbError> {
self.send_json_rpc_request::<std::string::String, std::vec::Vec<serde_json::Value>>(
solana_rpc_client_api::request::RpcRequest::GetHealth,
vec![],
id,
)
.await
}
/// Calls the `getSlot` RPC method.
pub async fn get_slot(
&self,
id: u64,
) -> core::result::Result<KhbbHttpRpcCallOutput<u64>, crate::KhbbError> {
self.send_json_rpc_request::<u64, std::vec::Vec<serde_json::Value>>(
solana_rpc_client_api::request::RpcRequest::GetSlot,
vec![],
id,
)
.await
}
/// Calls the `getVersion` RPC method.
pub async fn get_version(
&self,
id: u64,
) -> core::result::Result<
KhbbHttpRpcCallOutput<solana_rpc_client_api::response::RpcVersionInfo>,
crate::KhbbError,
> {
self.send_json_rpc_request::<
solana_rpc_client_api::response::RpcVersionInfo,
std::vec::Vec<serde_json::Value>,
>(
solana_rpc_client_api::request::RpcRequest::GetVersion,
vec![],
id,
)
.await
}
}
pub(crate) fn rpc_request_method_name(
rpc_request: solana_rpc_client_api::request::RpcRequest,
) -> core::result::Result<&'static str, crate::KhbbError> {
match rpc_request {
solana_rpc_client_api::request::RpcRequest::GetHealth => Ok("getHealth"),
solana_rpc_client_api::request::RpcRequest::GetSlot => Ok("getSlot"),
solana_rpc_client_api::request::RpcRequest::GetVersion => Ok("getVersion"),
_ => Err(crate::KhbbError::Config {
message: std::format!(
"unsupported rpc request variant in khbb minimal http client: {:?}",
rpc_request
),
}),
}
}
/// Builds a minimal JSON-RPC request object.
pub(crate) fn build_json_rpc_request<TParams>(
rpc_request: solana_rpc_client_api::request::RpcRequest,
params: TParams,
id: u64,
) -> core::result::Result<KhbbJsonRpcRequestEnvelope<TParams>, crate::KhbbError>
where
TParams: serde::Serialize,
{
let method_name_result = rpc_request_method_name(rpc_request);
let method_name = match method_name_result {
Ok(value) => value,
Err(error) => {
return Err(error);
},
};
Ok(KhbbJsonRpcRequestEnvelope {
jsonrpc: std::string::String::from("2.0"),
method: std::string::String::from(method_name),
params,
id,
})
}
/// Parses a minimal JSON-RPC response body.
pub(crate) fn parse_json_rpc_response<TResult>(
response_body: &str,
) -> core::result::Result<KhbbJsonRpcResponseEnvelope<TResult>, crate::KhbbError>
where
TResult: serde::Serialize + serde::de::DeserializeOwned,
{
let parse_result = serde_json::from_str::<KhbbJsonRpcResponseEnvelope<TResult>>(response_body);
match parse_result {
Ok(value) => Ok(value),
Err(error) => Err(crate::KhbbError::Json {
context: "parse json rpc response",
message: error.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
#[test]
fn rpc_request_method_name_maps_supported_variants() {
let get_health_result =
super::rpc_request_method_name(solana_rpc_client_api::request::RpcRequest::GetHealth);
let get_slot_result =
super::rpc_request_method_name(solana_rpc_client_api::request::RpcRequest::GetSlot);
let get_version_result =
super::rpc_request_method_name(solana_rpc_client_api::request::RpcRequest::GetVersion);
assert!(get_health_result.is_ok());
assert!(get_slot_result.is_ok());
assert!(get_version_result.is_ok());
assert_eq!(get_health_result.expect("getHealth"), "getHealth");
assert_eq!(get_slot_result.expect("getSlot"), "getSlot");
assert_eq!(get_version_result.expect("getVersion"), "getVersion");
}
#[test]
fn build_json_rpc_request_sets_expected_fields() {
let request_result = super::build_json_rpc_request(
solana_rpc_client_api::request::RpcRequest::GetSlot,
std::vec::Vec::<serde_json::Value>::new(),
7,
);
assert!(request_result.is_ok());
let request = request_result.expect("build request");
assert_eq!(request.jsonrpc, "2.0");
assert_eq!(request.method, "getSlot");
assert_eq!(request.id, 7);
assert!(request.params.is_empty());
}
#[test]
fn parse_json_rpc_response_accepts_u64_success_payload() {
let body = r#"{
"jsonrpc":"2.0",
"result":123456,
"id":1
}"#;
let result = super::parse_json_rpc_response::<u64>(body);
assert!(result.is_ok());
let response = result.expect("parse success response");
assert_eq!(response.jsonrpc, "2.0");
assert_eq!(response.result, Some(123456));
assert!(response.error.is_none());
}
#[test]
fn parse_json_rpc_response_accepts_string_success_payload() {
let body = r#"{
"jsonrpc":"2.0",
"result":"ok",
"id":1
}"#;
let result = super::parse_json_rpc_response::<std::string::String>(body);
assert!(result.is_ok());
let response = result.expect("parse string response");
assert_eq!(response.result.as_deref(), Some("ok"));
assert!(response.error.is_none());
}
#[test]
fn parse_json_rpc_response_accepts_error_payload() {
let body = r#"{
"jsonrpc":"2.0",
"error":{"code":-32000,"message":"failure","data":null},
"id":1
}"#;
let result = super::parse_json_rpc_response::<u64>(body);
assert!(result.is_ok());
let response = result.expect("parse error response");
assert!(response.result.is_none());
assert!(response.error.is_some());
}
#[test]
fn parse_json_rpc_response_accepts_rpc_version_info_payload() {
let body = r#"{
"jsonrpc":"2.0",
"result":{"solana-core":"2.3.3","feature-set":123},
"id":1
}"#;
let result =
super::parse_json_rpc_response::<solana_rpc_client_api::response::RpcVersionInfo>(body);
assert!(result.is_ok());
let response = result.expect("parse rpc version info response");
assert!(response.result.is_some());
assert!(response.error.is_none());
}
#[test]
fn parse_json_rpc_response_rejects_invalid_json() {
let body = r#"not-json"#;
let result = super::parse_json_rpc_response::<u64>(body);
assert!(result.is_err());
}
#[test]
fn new_rejects_empty_url() {
let config = super::KhbbSolanaHttpRpcClientConfig { url: std::string::String::new() };
let result = super::KhbbSolanaHttpRpcClient::new(config);
assert!(result.is_err());
}
#[test]
fn new_accepts_valid_url() {
let config = super::KhbbSolanaHttpRpcClientConfig {
url: std::string::String::from("https://example.invalid"),
};
let result = super::KhbbSolanaHttpRpcClient::new(config);
assert!(result.is_ok());
}
}