// 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 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 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, /// Raw error object if present. pub error: std::option::Option, /// Response identifier. pub id: serde_json::Value, } /// HTTP RPC response bundle containing both raw and parsed payloads. pub struct KhbbHttpRpcCallOutput where TResult: serde::Serialize + serde::de::DeserializeOwned, { /// 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, } /// 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 { 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( &self, rpc_request: solana_rpc_client_api::request::RpcRequest, params: TParams, id: u64, ) -> core::result::Result, crate::KhbbError> where TResult: serde::Serialize + serde::de::DeserializeOwned, TParams: serde::Serialize, { let request_result = build_json_rpc_request(rpc_request, params, id); 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::(&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_body, response_body, response: parsed_response, }) } /// Calls the `getHealth` RPC method. pub async fn get_health( &self, id: u64, ) -> core::result::Result, crate::KhbbError> { self.send_json_rpc_request::>( 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, crate::KhbbError> { self.send_json_rpc_request::>( 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, crate::KhbbError, > { self.send_json_rpc_request::< solana_rpc_client_api::response::RpcVersionInfo, std::vec::Vec, >( 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( rpc_request: solana_rpc_client_api::request::RpcRequest, params: TParams, id: u64, ) -> core::result::Result, 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( response_body: &str, ) -> core::result::Result, crate::KhbbError> where TResult: serde::Serialize + serde::de::DeserializeOwned, { let parse_result = serde_json::from_str::>(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::::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::(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::(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::(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::(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::(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()); } }