diff --git a/CHANGELOG.md b/CHANGELOG.md index 5916ef0..1cf3c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ 0.3.4 - Ajout de la fenêtre Demo Ws dans kb_app pour tester les souscriptions live 0.3.5 - Stabilisation de Demo Ws, lecture correcte des endpoints activés depuis la config, limitation/throttling de l’affichage UI sous fort débit 0.4.0 - Socle HttpClient générique async clonable, JSON-RPC HTTP 2.0, résolution d’URL avec api_key_env_var, limiteur local req/sec + burst, helpers initiaux getHealth/getVersion/getSlot +0.4.1 - Ajout des premiers helpers HTTP Solana haut niveau, dans la continuité de l’API du client WebSocket diff --git a/Cargo.toml b/Cargo.toml index 1701e7e..0d455f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.4.0" +version = "0.4.1" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bobobot" diff --git a/kb_lib/src/http_client.rs b/kb_lib/src/http_client.rs index 30194cc..9e5253e 100644 --- a/kb_lib/src/http_client.rs +++ b/kb_lib/src/http_client.rs @@ -5,12 +5,9 @@ //! This module provides a reusable `HttpClient` built on top of `reqwest` for //! Solana RPC HTTP endpoints. //! -//! Version `0.4.0` keeps the API intentionally small: -//! - reusable async client -//! - endpoint URL resolution via config -//! - local req/sec + burst limiter -//! - generic JSON-RPC 2.0 request/response handling -//! - a few initial Solana validation helpers +//! Version `0.4.1` extends the `0.4.0` transport layer with: +//! - raw Solana HTTP helpers +//! - typed Solana HTTP helpers using `solana_rpc_client_api` types /// JSON-RPC 2.0 request envelope for HTTP. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -291,6 +288,20 @@ impl HttpClient { } } + /// Executes one JSON-RPC request and returns only the raw `result` field. + pub async fn execute_json_rpc_result_raw( + &self, + method: std::string::String, + params: std::vec::Vec, + ) -> Result { + let raw_result = self.execute_json_rpc_request_raw(method, params).await; + let raw_response = match raw_result { + Ok(raw_response) => raw_response, + Err(error) => return Err(error), + }; + Ok(raw_response.result) + } + /// Executes one JSON-RPC request and decodes `result` into `T`. pub async fn execute_json_rpc_request_typed( &self, @@ -300,12 +311,12 @@ impl HttpClient { where T: serde::de::DeserializeOwned, { - let raw_result = self.execute_json_rpc_request_raw(method, params).await; - let raw_response = match raw_result { - Ok(raw_response) => raw_response, + let raw_result = self.execute_json_rpc_result_raw(method, params).await; + let raw_value = match raw_result { + Ok(raw_value) => raw_value, Err(error) => return Err(error), }; - let typed_result = serde_json::from_value::(raw_response.result); + let typed_result = serde_json::from_value::(raw_value); match typed_result { Ok(value) => Ok(value), Err(error) => Err(crate::KbError::Json(format!( @@ -314,46 +325,259 @@ impl HttpClient { } } - /// Calls `getHealth`. - pub async fn get_health(&self) -> Result { - let raw_result = self - .execute_json_rpc_request_raw("getHealth".to_string(), std::vec::Vec::new()) - .await; - match raw_result { - Ok(response) => Ok(response.result), - Err(error) => Err(error), - } + /// Raw helper for `getHealth`. + pub async fn get_health_raw(&self) -> Result { + self.execute_json_rpc_result_raw("getHealth".to_string(), std::vec::Vec::new()) + .await } - /// Calls `getVersion`. - pub async fn get_version(&self) -> Result { - let raw_result = self - .execute_json_rpc_request_raw("getVersion".to_string(), std::vec::Vec::new()) - .await; - match raw_result { - Ok(response) => Ok(response.result), - Err(error) => Err(error), - } + /// Typed helper for `getHealth`. + pub async fn get_health(&self) -> Result { + self.execute_json_rpc_request_typed::( + "getHealth".to_string(), + std::vec::Vec::new(), + ) + .await } - /// Calls `getSlot`. + /// Raw helper for `getVersion`. + pub async fn get_version_raw(&self) -> Result { + self.execute_json_rpc_result_raw("getVersion".to_string(), std::vec::Vec::new()) + .await + } + + /// Typed helper for `getVersion`. + pub async fn get_version( + &self, + ) -> Result { + self.execute_json_rpc_request_typed::( + "getVersion".to_string(), + std::vec::Vec::new(), + ) + .await + } + + /// Raw helper for `getSlot`. + pub async fn get_slot_raw( + &self, + config: std::option::Option, + ) -> Result { + let params = kb_build_optional_config_only_params(config); + self.execute_json_rpc_result_raw("getSlot".to_string(), params) + .await + } + + /// Typed helper for `getSlot`. pub async fn get_slot( &self, - commitment: std::option::Option, + config: std::option::Option, + ) -> Result { + let config_value_result = kb_serialize_optional_json_value(config, "getSlot config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_optional_config_only_params(config_value); + self.execute_json_rpc_request_typed::("getSlot".to_string(), params) + .await + } + + /// Raw helper for `getBlockHeight`. + pub async fn get_block_height_raw( + &self, + config: std::option::Option, ) -> Result { - let mut params = std::vec::Vec::new(); - if let Some(commitment) = commitment { - params.push(serde_json::json!({ - "commitment": commitment - })); - } - let raw_result = self - .execute_json_rpc_request_raw("getSlot".to_string(), params) - .await; - match raw_result { - Ok(response) => Ok(response.result), - Err(error) => Err(error), - } + let params = kb_build_optional_config_only_params(config); + self.execute_json_rpc_result_raw("getBlockHeight".to_string(), params) + .await + } + + /// Typed helper for `getBlockHeight`. + pub async fn get_block_height( + &self, + config: std::option::Option, + ) -> Result { + let config_value_result = kb_serialize_optional_json_value(config, "getBlockHeight config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_optional_config_only_params(config_value); + self.execute_json_rpc_request_typed::("getBlockHeight".to_string(), params) + .await + } + + /// Raw helper for `getLatestBlockhash`. + pub async fn get_latest_blockhash_raw( + &self, + config: std::option::Option, + ) -> Result { + let params = kb_build_optional_config_only_params(config); + self.execute_json_rpc_result_raw("getLatestBlockhash".to_string(), params) + .await + } + + /// Typed helper for `getLatestBlockhash`. + pub async fn get_latest_blockhash( + &self, + config: std::option::Option, + ) -> Result< + solana_rpc_client_api::response::Response, + crate::KbError, + > { + let config_value_result = + kb_serialize_optional_json_value(config, "getLatestBlockhash config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_optional_config_only_params(config_value); + self.execute_json_rpc_request_typed::< + solana_rpc_client_api::response::Response< + solana_rpc_client_api::response::RpcBlockhash, + >, + >("getLatestBlockhash".to_string(), params) + .await + } + + /// Raw helper for `getBalance`. + pub async fn get_balance_raw( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result { + let params = kb_build_first_string_optional_config_params(address, config); + self.execute_json_rpc_result_raw("getBalance".to_string(), params) + .await + } + + /// Typed helper for `getBalance`. + pub async fn get_balance( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result, crate::KbError> { + let config_value_result = kb_serialize_optional_json_value(config, "getBalance config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_first_string_optional_config_params(address, config_value); + self.execute_json_rpc_request_typed::>( + "getBalance".to_string(), + params, + ) + .await + } + + /// Raw helper for `getAccountInfo`. + pub async fn get_account_info_raw( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result { + let params = kb_build_first_string_optional_config_params(address, config); + self.execute_json_rpc_result_raw("getAccountInfo".to_string(), params) + .await + } + + /// Typed helper for `getAccountInfo`. + pub async fn get_account_info( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result< + solana_rpc_client_api::response::Response< + std::option::Option, + >, + crate::KbError, + > { + let config_value_result = kb_serialize_optional_json_value(config, "getAccountInfo config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_first_string_optional_config_params(address, config_value); + self.execute_json_rpc_request_typed::, + >>("getAccountInfo".to_string(), params) + .await + } + + /// Raw helper for `getProgramAccounts`. + pub async fn get_program_accounts_raw( + &self, + program_id: std::string::String, + config: std::option::Option, + ) -> Result { + let params = kb_build_first_string_optional_config_params(program_id, config); + self.execute_json_rpc_result_raw("getProgramAccounts".to_string(), params) + .await + } + + /// Raw helper for `getSignaturesForAddress`. + pub async fn get_signatures_for_address_raw( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result { + let params = kb_build_first_string_optional_config_params(address, config); + self.execute_json_rpc_result_raw("getSignaturesForAddress".to_string(), params) + .await + } + + /// Typed helper for `getSignaturesForAddress`. + pub async fn get_signatures_for_address( + &self, + address: std::string::String, + config: std::option::Option, + ) -> Result< + std::vec::Vec, + crate::KbError, + > { + let config_value_result = + kb_serialize_optional_json_value(config, "getSignaturesForAddress config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_first_string_optional_config_params(address, config_value); + self.execute_json_rpc_request_typed::>("getSignaturesForAddress".to_string(), params) + .await + } + + /// Raw helper for `getTransaction`. + pub async fn get_transaction_raw( + &self, + signature: std::string::String, + config: std::option::Option, + ) -> Result { + let params = kb_build_first_string_optional_config_params(signature, config); + self.execute_json_rpc_result_raw("getTransaction".to_string(), params) + .await + } + + /// Typed helper for `getTransaction`. + pub async fn get_transaction( + &self, + signature: std::string::String, + config: std::option::Option, + ) -> Result< + std::option::Option, + crate::KbError, + > { + let config_value_result = kb_serialize_optional_json_value(config, "getTransaction config"); + let config_value = match config_value_result { + Ok(config_value) => config_value, + Err(error) => return Err(error), + }; + let params = kb_build_first_string_optional_config_params(signature, config_value); + self.execute_json_rpc_request_typed::< + std::option::Option, + >("getTransaction".to_string(), params) + .await } async fn acquire_rate_limit_slot(&self) -> Result<(), crate::KbError> { @@ -420,7 +644,6 @@ pub fn parse_kb_json_rpc_http_response_value( )); } }; - let jsonrpc_value_option = object.get("jsonrpc"); let jsonrpc_value = match jsonrpc_value_option { Some(jsonrpc_value) => jsonrpc_value, @@ -430,7 +653,6 @@ pub fn parse_kb_json_rpc_http_response_value( )); } }; - let jsonrpc_string_option = jsonrpc_value.as_str(); let jsonrpc_string = match jsonrpc_string_option { Some(jsonrpc_string) => jsonrpc_string, @@ -440,18 +662,15 @@ pub fn parse_kb_json_rpc_http_response_value( )); } }; - if jsonrpc_string != "2.0" { return Err(crate::KbError::Json(format!( "unsupported http json-rpc version '{}'", jsonrpc_string ))); } - let has_result = object.contains_key("result"); let has_error = object.contains_key("error"); let has_id = object.contains_key("id"); - if has_id && has_result && !has_error { let response_result = serde_json::from_value::(value.clone()); return match response_result { @@ -461,7 +680,6 @@ pub fn parse_kb_json_rpc_http_response_value( ))), }; } - if has_id && has_error && !has_result { let response_result = serde_json::from_value::(value.clone()); return match response_result { @@ -476,6 +694,49 @@ pub fn parse_kb_json_rpc_http_response_value( )) } +fn kb_build_optional_config_only_params( + config: std::option::Option, +) -> std::vec::Vec { + let mut params = std::vec::Vec::new(); + if let Some(config) = config { + params.push(config); + } + params +} + +fn kb_build_first_string_optional_config_params( + first: std::string::String, + config: std::option::Option, +) -> std::vec::Vec { + let mut params = vec![serde_json::Value::String(first)]; + if let Some(config) = config { + params.push(config); + } + params +} + +fn kb_serialize_optional_json_value( + value: std::option::Option, + label: &str, +) -> Result, crate::KbError> +where + T: serde::Serialize, +{ + match value { + Some(value) => { + let value_result = serde_json::to_value(value); + match value_result { + Ok(value) => Ok(Some(value)), + Err(error) => Err(crate::KbError::Json(format!( + "cannot serialize {}: {error}", + label + ))), + } + } + None => Ok(None), + } +} + fn kb_http_shorten_text(input: &str, max_chars: usize) -> std::string::String { let char_count = input.chars().count(); if char_count <= max_chars { @@ -517,7 +778,7 @@ mod tests { let (mut stream, _peer_addr) = accept_result.expect("accept must succeed"); let observed_methods_for_connection = observed_methods_for_server.clone(); tokio::spawn(async move { - let mut buffer = vec![0u8; 8192]; + let mut buffer = vec![0u8; 65536]; let read_result = stream.read(&mut buffer).await; let bytes_read = read_result.expect("read must succeed"); let request_text = @@ -562,6 +823,82 @@ mod tests { "result": 424242u64, "id": id }).to_string() + } else if method == "getBlockHeight" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": 919191u64, + "id": id + }).to_string() + } else if method == "getLatestBlockhash" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 999u64 + }, + "value": { + "blockhash": "11111111111111111111111111111111", + "lastValidBlockHeight": 12345u64 + } + }, + "id": id + }).to_string() + } else if method == "getBalance" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 77u64 + }, + "value": 5000u64 + }, + "id": id + }).to_string() + } else if method == "getSignaturesForAddress" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": [ + { + "signature": "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7", + "slot": 88u64, + "err": null, + "memo": null, + "blockTime": 1700000000i64, + "confirmationStatus": "finalized" + } + ], + "id": id + }).to_string() + } else if method == "getTransaction" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": null, + "id": id + }).to_string() + } else if method == "getAccountInfo" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": { + "context": { + "slot": 55u64 + }, + "value": { + "data": ["", "base64"], + "executable": false, + "lamports": 1u64, + "owner": "11111111111111111111111111111111", + "rentEpoch": 0u64, + "space": 0u64 + } + }, + "id": id + }).to_string() + } else if method == "getProgramAccounts" { + serde_json::json!({ + "jsonrpc": "2.0", + "result": [], + "id": id + }).to_string() } else { serde_json::json!({ "jsonrpc": "2.0", @@ -602,7 +939,7 @@ mod tests { } } } - + fn make_http_endpoint(url: std::string::String) -> crate::KbHttpEndpointConfig { crate::KbHttpEndpointConfig { name: "test_http".to_string(), @@ -664,48 +1001,165 @@ mod tests { } #[tokio::test] - async fn get_health_works() { + async fn typed_helpers_work_for_basic_methods() { let server = TestHttpServer::spawn().await; let endpoint = make_http_endpoint(server.url.clone()); let client = crate::HttpClient::new(endpoint).expect("client creation must succeed"); - let result = client.get_health().await.expect("get_health must succeed"); - assert_eq!(result, serde_json::Value::String("ok".to_string())); - let observed_methods = server.observed_methods_snapshot().await; - assert!(observed_methods.iter().any(|method| method == "getHealth")); - server.shutdown().await; - } - - #[tokio::test] - async fn get_version_works() { - let server = TestHttpServer::spawn().await; - let endpoint = make_http_endpoint(server.url.clone()); - let client = crate::HttpClient::new(endpoint).expect("client creation must succeed"); - let result = client + let health = client.get_health().await.expect("get_health must succeed"); + assert_eq!(health, "ok".to_string()); + let version = client .get_version() .await .expect("get_version must succeed"); + assert_eq!(version.solana_core, "2.2.3".to_string()); + let slot = client.get_slot(None).await.expect("get_slot must succeed"); + assert_eq!(slot, 424242u64); + let block_height = client + .get_block_height(None) + .await + .expect("get_block_height must succeed"); + assert_eq!(block_height, 919191u64); + let latest_blockhash = client + .get_latest_blockhash(None) + .await + .expect("get_latest_blockhash must succeed"); + assert_eq!(latest_blockhash.context.slot, 999u64); assert_eq!( - result["solana-core"], - serde_json::Value::String("2.2.3".to_string()) + latest_blockhash.value.blockhash, + "11111111111111111111111111111111".to_string() ); - assert_eq!(result["feature-set"], serde_json::Value::from(123u64)); + assert_eq!(latest_blockhash.value.last_valid_block_height, 12345u64); + let balance = client + .get_balance("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_balance must succeed"); + assert_eq!(balance.context.slot, 77u64); + assert_eq!(balance.value, 5000u64); + let signatures = client + .get_signatures_for_address("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_signatures_for_address must succeed"); + assert_eq!(signatures.len(), 1); + assert_eq!(signatures[0].slot, 88u64); + let transaction = client + .get_transaction( + "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(), + None, + ) + .await + .expect("get_transaction must succeed"); + assert!(transaction.is_none()); let observed_methods = server.observed_methods_snapshot().await; + assert!(observed_methods.iter().any(|method| method == "getHealth")); assert!(observed_methods.iter().any(|method| method == "getVersion")); + assert!(observed_methods.iter().any(|method| method == "getSlot")); + assert!( + observed_methods + .iter() + .any(|method| method == "getBlockHeight") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getLatestBlockhash") + ); + assert!(observed_methods.iter().any(|method| method == "getBalance")); + assert!( + observed_methods + .iter() + .any(|method| method == "getSignaturesForAddress") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getTransaction") + ); server.shutdown().await; } #[tokio::test] - async fn get_slot_works() { + async fn raw_helpers_send_expected_methods() { let server = TestHttpServer::spawn().await; let endpoint = make_http_endpoint(server.url.clone()); let client = crate::HttpClient::new(endpoint).expect("client creation must succeed"); - let result = client - .get_slot(Some("finalized".to_string())) + let _ = client + .get_health_raw() .await - .expect("get_slot must succeed"); - assert_eq!(result, serde_json::Value::from(424242u64)); + .expect("get_health_raw must succeed"); + let _ = client + .get_version_raw() + .await + .expect("get_version_raw must succeed"); + let _ = client + .get_slot_raw(None) + .await + .expect("get_slot_raw must succeed"); + let _ = client + .get_block_height_raw(None) + .await + .expect("get_block_height_raw must succeed"); + let _ = client + .get_latest_blockhash_raw(None) + .await + .expect("get_latest_blockhash_raw must succeed"); + let _ = client + .get_balance_raw("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_balance_raw must succeed"); + let _ = client + .get_account_info_raw("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_account_info_raw must succeed"); + let _ = client + .get_program_accounts_raw("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_program_accounts_raw must succeed"); + let _ = client + .get_signatures_for_address_raw("11111111111111111111111111111111".to_string(), None) + .await + .expect("get_signatures_for_address_raw must succeed"); + let _ = client + .get_transaction_raw( + "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(), + None, + ) + .await + .expect("get_transaction_raw must succeed"); let observed_methods = server.observed_methods_snapshot().await; + assert!(observed_methods.iter().any(|method| method == "getHealth")); + assert!(observed_methods.iter().any(|method| method == "getVersion")); assert!(observed_methods.iter().any(|method| method == "getSlot")); + assert!( + observed_methods + .iter() + .any(|method| method == "getBlockHeight") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getLatestBlockhash") + ); + assert!(observed_methods.iter().any(|method| method == "getBalance")); + assert!( + observed_methods + .iter() + .any(|method| method == "getAccountInfo") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getProgramAccounts") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getSignaturesForAddress") + ); + assert!( + observed_methods + .iter() + .any(|method| method == "getTransaction") + ); server.shutdown().await; }