From 0dc797298e7fa5770ebe3edf36b1da8cd6acaedb Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Fri, 17 Apr 2026 20:40:06 +0200 Subject: [PATCH] 0.3.1 --- Cargo.toml | 2 +- khbb_lib/src/lib.rs | 11 + khbb_lib/src/solana_rpc_http.rs | 394 ++++++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 khbb_lib/src/solana_rpc_http.rs diff --git a/Cargo.toml b/Cargo.toml index 8673fe0..767675c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobot" diff --git a/khbb_lib/src/lib.rs b/khbb_lib/src/lib.rs index a6ad36b..f83270c 100644 --- a/khbb_lib/src/lib.rs +++ b/khbb_lib/src/lib.rs @@ -14,6 +14,7 @@ mod config; mod domain; mod error; mod listener; +mod solana_rpc_http; mod storage; mod tracing_setup; @@ -27,6 +28,16 @@ pub use crate::domain::KhbbListenerSession; pub use crate::error::KhbbError; /// Runs the current listener runtime skeleton. pub use crate::listener::run_listener_runtime; +/// Minimal JSON-RPC request envelope. +pub use crate::solana_rpc_http::KhbbJsonRpcRequestEnvelope; +/// Minimal JSON-RPC response envelope. +pub use crate::solana_rpc_http::KhbbJsonRpcResponseEnvelope; +/// HTTP RPC response bundle with raw and parsed payloads. +pub use crate::solana_rpc_http::KhbbHttpRpcCallOutput; +/// Minimal Solana HTTP JSON-RPC client. +pub use crate::solana_rpc_http::KhbbSolanaHttpRpcClient; +/// Configuration of the minimal Solana HTTP JSON-RPC client. +pub use crate::solana_rpc_http::KhbbSolanaHttpRpcClientConfig; /// Creates and initializes the SQLite storage layer. pub use crate::storage::create_sqlite_pool; /// Ensures the SQLite schema is present. diff --git a/khbb_lib/src/solana_rpc_http.rs b/khbb_lib/src/solana_rpc_http.rs new file mode 100644 index 0000000..e8d2236 --- /dev/null +++ b/khbb_lib/src/solana_rpc_http.rs @@ -0,0 +1,394 @@ +// 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()); + } +}