From 184356fd6f0836b821cc4b48881d873a688c9a4d Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:28:17 +1200 Subject: [PATCH 01/17] add base64 dep --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 074c61f..0a4078d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ license = "MIT/Apache-2.0" repository = "https://github.com/blocklessnetwork/sdk-rust" [dependencies] +base64 = { version = "0.13", default-features = false, features = ["alloc"] } htmd = { version = "0.2.2", default-features = false } json = { version = "0.12", default-features = false } kuchikiki = { version = "0.8", default-features = false } From d4077fe24cb42f2ccc6365cccddcb540d0ffb45c Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:28:29 +1200 Subject: [PATCH 02/17] new http client --- src/http.rs | 949 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 786 insertions(+), 163 deletions(-) diff --git a/src/http.rs b/src/http.rs index 6543378..76ddc04 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,217 +1,840 @@ -use crate::error::HttpErrorKind; -use json::JsonValue; -use std::{cmp::Ordering, collections::BTreeMap}; +use std::collections::HashMap; +use std::fmt::Write; + +type ExitCode = u32; #[cfg(not(feature = "mock-ffi"))] #[link(wasm_import_module = "blockless_http")] extern "C" { - #[link_name = "http_req"] - pub(crate) fn http_open( - url: *const u8, + #[link_name = "http_call"] + fn http_call( + url_ptr: *const u8, url_len: u32, - opts: *const u8, - opts_len: u32, - fd: *mut u32, - status: *mut u32, - ) -> u32; - - #[link_name = "http_read_header"] - pub(crate) fn http_read_header( - handle: u32, - header: *const u8, - header_len: u32, - buf: *mut u8, - buf_len: u32, - num: *mut u32, - ) -> u32; - - #[link_name = "http_read_body"] - pub(crate) fn http_read_body(handle: u32, buf: *mut u8, buf_len: u32, num: *mut u32) -> u32; - - #[link_name = "http_close"] - pub(crate) fn http_close(handle: u32) -> u32; + options_ptr: *const u8, + options_len: u32, + result_ptr: *mut u8, + result_max_len: u32, + bytes_written_ptr: *mut u32, + ) -> ExitCode; } #[cfg(feature = "mock-ffi")] #[allow(unused_variables)] mod mock_ffi { + use super::*; - pub unsafe fn http_open( - _url: *const u8, + pub unsafe fn http_call( + _url_ptr: *const u8, _url_len: u32, - _opts: *const u8, - _opts_len: u32, - fd: *mut u32, - status: *mut u32, - ) -> u32 { - unimplemented!() + _options_ptr: *const u8, + _options_len: u32, + result_ptr: *mut u8, + result_max_len: u32, + bytes_written_ptr: *mut u32, + ) -> ExitCode { + let mock_response = r#"{"success":true,"data":{"status":200,"headers":{"content-type":"application/json"},"body":[123,34,104,101,108,108,111,34,58,34,119,111,114,108,100,34,125],"url":"https://httpbin.org/get"}}"#; + let response_bytes = mock_response.as_bytes(); + let write_len = std::cmp::min(response_bytes.len(), result_max_len as usize); + std::ptr::copy_nonoverlapping(response_bytes.as_ptr(), result_ptr, write_len); + *bytes_written_ptr = write_len as u32; + 0 + } +} + +#[cfg(feature = "mock-ffi")] +use mock_ffi::*; + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct HttpOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub query_params: Option>, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(untagged)] +pub enum HttpBody { + Text(String), + Binary(Vec), + Form(HashMap), + Multipart(Vec), +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct MultipartField { + pub name: String, + pub value: MultipartValue, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum MultipartValue { + Text(String), + Binary { + data: Vec, + filename: Option, + content_type: Option, + }, +} + +impl Default for HttpOptions { + fn default() -> Self { + Self { + method: Some("GET".to_string()), + headers: None, + body: None, + timeout: Some(30000), // 30 seconds default + query_params: None, + } + } +} + +impl HttpOptions { + pub fn new() -> Self { + Self::default() } - pub unsafe fn http_read_header( - _handle: u32, - _header: *const u8, - _header_len: u32, - buf: *mut u8, - buf_len: u32, - num: *mut u32, - ) -> u32 { - unimplemented!() + pub fn method>(mut self, method: S) -> Self { + self.method = Some(method.into()); + self + } + + pub fn header, V: Into>(mut self, key: K, value: V) -> Self { + if self.headers.is_none() { + self.headers = Some(HashMap::new()); + } + self.headers + .as_mut() + .unwrap() + .insert(key.into(), value.into()); + self } - pub unsafe fn http_read_body(_handle: u32, buf: *mut u8, buf_len: u32, num: *mut u32) -> u32 { - unimplemented!() + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers = Some(headers); + self } - pub unsafe fn http_close(_handle: u32) -> u32 { - unimplemented!() + pub fn body>(mut self, body: S) -> Self { + self.body = Some(HttpBody::Text(body.into())); + self + } + + pub fn body_binary(mut self, data: Vec) -> Self { + self.body = Some(HttpBody::Binary(data)); + self + } + + pub fn form(mut self, form_data: HashMap) -> Self { + self.body = Some(HttpBody::Form(form_data)); + self = self.header("Content-Type", "application/x-www-form-urlencoded"); + self + } + + pub fn multipart(mut self, fields: Vec) -> Self { + self.body = Some(HttpBody::Multipart(fields)); + // Note: Content-Type with boundary will be set by the host function + self + } + + pub fn timeout(mut self, timeout_ms: u32) -> Self { + self.timeout = Some(timeout_ms); + self + } + + pub fn json(mut self, data: &T) -> Result { + let json_body = serde_json::to_string(data).map_err(|_| HttpError::SerializationError)?; + self.body = Some(HttpBody::Text(json_body)); + self = self.header("Content-Type", "application/json"); + Ok(self) + } + + pub fn basic_auth, P: Into>(self, username: U, password: P) -> Self { + let credentials = format!("{}:{}", username.into(), password.into()); + let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD); + self.header("Authorization", format!("Basic {}", encoded)) + } + + pub fn bearer_auth>(self, token: T) -> Self { + self.header("Authorization", format!("Bearer {}", token.into())) + } + + pub fn query_param, V: Into>(mut self, key: K, value: V) -> Self { + if self.query_params.is_none() { + self.query_params = Some(HashMap::new()); + } + self.query_params + .as_mut() + .unwrap() + .insert(key.into(), value.into()); + self + } + + pub fn query_params(mut self, params: HashMap) -> Self { + self.query_params = Some(params); + self } } -#[cfg(feature = "mock-ffi")] -use mock_ffi::*; +pub struct HttpClientBuilder { + default_headers: Option>, + timeout: Option, +} -type Handle = u32; -type ExitCode = u32; +impl Default for HttpClientBuilder { + fn default() -> Self { + Self::new() + } +} -pub struct BlocklessHttp { - inner: Handle, - code: ExitCode, +impl HttpClientBuilder { + pub fn new() -> Self { + Self { + default_headers: None, + timeout: Some(30000), + } + } + + pub fn default_headers(mut self, headers: HashMap) -> Self { + self.default_headers = Some(headers); + self + } + + pub fn timeout(mut self, timeout: u32) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn build(self) -> HttpClient { + HttpClient { + default_headers: self.default_headers, + timeout: self.timeout, + } + } } -pub struct HttpOptions { - pub method: String, - pub connect_timeout: u32, - pub read_timeout: u32, - pub body: Option, - pub headers: Option>, +impl Clone for HttpClient { + fn clone(&self) -> Self { + Self { + default_headers: self.default_headers.clone(), + timeout: self.timeout, + } + } } -impl HttpOptions { - pub fn new(method: &str, connect_timeout: u32, read_timeout: u32) -> Self { - HttpOptions { - method: method.into(), - connect_timeout, - read_timeout, +impl RequestBuilder { + pub fn header, V: Into>(mut self, key: K, value: V) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers.extend(headers); + self + } + + pub fn query, V: Into>(mut self, key: K, value: V) -> Self { + self.query_params.insert(key.into(), value.into()); + self + } + + pub fn query_params(mut self, params: HashMap) -> Self { + self.query_params.extend(params); + self + } + + pub fn basic_auth, P: Into>( + mut self, + username: U, + password: P, + ) -> Self { + let credentials = format!("{}:{}", username.into(), password.into()); + let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD); + self.headers + .insert("Authorization".to_string(), format!("Basic {}", encoded)); + self + } + + pub fn bearer_auth>(mut self, token: T) -> Self { + self.headers.insert( + "Authorization".to_string(), + format!("Bearer {}", token.into()), + ); + self + } + + pub fn timeout(mut self, timeout: u32) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn body>(mut self, body: S) -> Self { + self.body = Some(HttpBody::Text(body.into())); + self + } + + pub fn body_bytes(mut self, body: Vec) -> Self { + self.body = Some(HttpBody::Binary(body)); + self + } + + pub fn form(mut self, form: HashMap) -> Self { + self.body = Some(HttpBody::Form(form)); + self.headers.insert( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + self + } + + pub fn multipart(mut self, form: Vec) -> Self { + self.body = Some(HttpBody::Multipart(form)); + self + } + + pub fn json(mut self, json: &T) -> Result { + let json_body = serde_json::to_string(json).map_err(|_| Error::SerializationError)?; + self.body = Some(HttpBody::Text(json_body)); + self.headers + .insert("Content-Type".to_string(), "application/json".to_string()); + Ok(self) + } + + pub fn send(self) -> Result { + self.client.execute(&self) + } +} + +pub type Response = HttpResponse; +pub type Error = HttpError; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + pub body: Vec, + pub url: String, +} + +impl HttpResponse { + pub fn text(&self) -> Result { + String::from_utf8(self.body.clone()).map_err(|_| HttpError::Utf8Error) + } + + pub fn json(&self) -> Result { + let text = self.text()?; + serde_json::from_str(&text).map_err(|_| HttpError::JsonParseError) + } + + pub fn bytes(&self) -> &[u8] { + &self.body + } + + pub fn status(&self) -> u16 { + self.status + } + + pub fn is_success(&self) -> bool { + self.status >= 200 && self.status < 300 + } + + pub fn headers(&self) -> &HashMap { + &self.headers + } + + pub fn header(&self, name: &str) -> Option<&String> { + self.headers.get(name) + } + + pub fn url(&self) -> &str { + &self.url + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub struct HttpClient { + default_headers: Option>, + timeout: Option, +} + +pub struct RequestBuilder { + client: HttpClient, + method: String, + url: String, + headers: HashMap, + query_params: HashMap, + body: Option, + timeout: Option, +} + +impl Default for HttpClient { + fn default() -> Self { + Self::new() + } +} + +impl HttpClient { + pub fn new() -> Self { + Self { + default_headers: None, + timeout: Some(30000), // 30 seconds default + } + } + + pub fn builder() -> HttpClientBuilder { + HttpClientBuilder::new() + } + + // HTTP verb methods - return RequestBuilder for chaining + pub fn get>(&self, url: U) -> RequestBuilder { + self.request("GET", url) + } + + pub fn post>(&self, url: U) -> RequestBuilder { + self.request("POST", url) + } + + pub fn put>(&self, url: U) -> RequestBuilder { + self.request("PUT", url) + } + + pub fn patch>(&self, url: U) -> RequestBuilder { + self.request("PATCH", url) + } + + pub fn delete>(&self, url: U) -> RequestBuilder { + self.request("DELETE", url) + } + + pub fn head>(&self, url: U) -> RequestBuilder { + self.request("HEAD", url) + } + + pub fn request>(&self, method: &str, url: U) -> RequestBuilder { + let mut headers = HashMap::new(); + if let Some(ref default_headers) = self.default_headers { + headers.extend(default_headers.clone()); + } + + RequestBuilder { + client: self.clone(), + method: method.to_string(), + url: url.into(), + headers, + query_params: HashMap::new(), body: None, - headers: None, + timeout: self.timeout, } } - pub fn dump(&self) -> String { - // convert BTreeMap to json string - let mut headers_str = self - .headers - .clone() - .unwrap_or_default() - .iter() - .map(|(k, v)| format!("\"{}\":\"{}\"", k, v)) - .collect::>() - .join(","); - headers_str = format!("{{{}}}", headers_str); - - let mut json = JsonValue::new_object(); - json["method"] = self.method.clone().into(); - json["connectTimeout"] = self.connect_timeout.into(); - json["readTimeout"] = self.read_timeout.into(); - json["headers"] = headers_str.into(); - json["body"] = self.body.clone().into(); - json.dump() - } -} - -impl BlocklessHttp { - pub fn open(url: &str, opts: &HttpOptions) -> Result { - let opts = opts.dump(); - let mut fd = 0; - let mut status = 0; - let rs = unsafe { - http_open( - url.as_ptr(), - url.len() as _, - opts.as_ptr(), - opts.len() as _, - &mut fd, - &mut status, + fn execute(&self, builder: &RequestBuilder) -> Result { + let options = HttpOptions { + method: Some(builder.method.clone()), + headers: if builder.headers.is_empty() { + None + } else { + Some(builder.headers.clone()) + }, + body: builder.body.clone(), + timeout: builder.timeout, + query_params: if builder.query_params.is_empty() { + None + } else { + Some(builder.query_params.clone()) + }, + }; + + self.make_request(&builder.url, options) + } + + fn make_request(&self, url: &str, options: HttpOptions) -> Result { + const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response + + if url.is_empty() { + return Err(Error::InvalidUrl); + } + + // Build final URL with query parameters + let final_url = if let Some(ref params) = options.query_params { + build_url_with_params(url, params) + } else { + url.to_string() + }; + + let options_json = + serde_json::to_string(&options).map_err(|_| Error::SerializationError)?; + + let mut result_buffer = vec![0u8; MAX_RESPONSE_SIZE]; + let mut bytes_written: u32 = 0; + + let exit_code = unsafe { + http_call( + final_url.as_ptr(), + final_url.len() as u32, + options_json.as_ptr(), + options_json.len() as u32, + result_buffer.as_mut_ptr(), + MAX_RESPONSE_SIZE as u32, + &mut bytes_written, ) }; - if rs != 0 { - return Err(HttpErrorKind::from(rs)); + + if exit_code != 0 { + return Err(Error::from_code(exit_code)); + } + + if bytes_written == 0 { + return Err(Error::EmptyResponse); + } + + let response_bytes = &result_buffer[..bytes_written as usize]; + + let http_result: HttpResult = + serde_json::from_slice(response_bytes).map_err(|_| Error::JsonParseError)?; + + if !http_result.success { + let error_msg = http_result + .error + .unwrap_or_else(|| "Unknown error".to_string()); + return Err(Error::RequestFailed(error_msg)); } - Ok(Self { - inner: fd, - code: status, - }) + + http_result.data.ok_or(Error::EmptyResponse) } +} + +#[derive(Debug, Clone)] +pub enum HttpError { + InvalidUrl, + SerializationError, + JsonParseError, + Utf8Error, + EmptyResponse, + RequestFailed(String), + NetworkError, + Timeout, + Unknown(u32), +} - pub fn get_code(&self) -> ExitCode { - self.code +impl HttpError { + fn from_code(code: u32) -> Self { + match code { + 1 => HttpError::InvalidUrl, + 2 => HttpError::Timeout, + 3 => HttpError::NetworkError, + _ => HttpError::Unknown(code), + } } +} - pub fn get_all_body(&self) -> Result, HttpErrorKind> { - let mut vec = Vec::new(); - loop { - let mut buf = [0u8; 1024]; - let mut num: u32 = 0; - let rs = - unsafe { http_read_body(self.inner, buf.as_mut_ptr(), buf.len() as _, &mut num) }; - if rs != 0 { - return Err(HttpErrorKind::from(rs)); - } +impl std::fmt::Display for HttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HttpError::InvalidUrl => write!(f, "Invalid URL provided"), + HttpError::SerializationError => write!(f, "Failed to serialize request data"), + HttpError::JsonParseError => write!(f, "Failed to parse JSON response"), + HttpError::Utf8Error => write!(f, "Invalid UTF-8 in response"), + HttpError::EmptyResponse => write!(f, "Empty response received"), + HttpError::RequestFailed(msg) => write!(f, "Request failed: {}", msg), + HttpError::NetworkError => write!(f, "Network error occurred"), + HttpError::Timeout => write!(f, "Request timed out"), + HttpError::Unknown(code) => write!(f, "Unknown error (code: {})", code), + } + } +} + +impl std::error::Error for HttpError {} - match num.cmp(&0) { - Ordering::Greater => vec.extend_from_slice(&buf[0..num as _]), - _ => break, +// Utility functions +pub fn build_url_with_params(base_url: &str, params: &HashMap) -> String { + if params.is_empty() { + return base_url.to_string(); + } + + let url_encode = |s: &str| -> String { + let mut encoded = String::new(); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + write!(&mut encoded, "%{:02X}", byte).unwrap(); + } } } - Ok(vec) - } - - pub fn get_header(&self, header: &str) -> Result { - let mut vec = Vec::new(); - loop { - let mut buf = [0u8; 1024]; - let mut num: u32 = 0; - let rs = unsafe { - http_read_header( - self.inner, - header.as_ptr(), - header.len() as _, - buf.as_mut_ptr(), - buf.len() as _, - &mut num, - ) - }; - if rs != 0 { - return Err(HttpErrorKind::from(rs)); - } - match num.cmp(&0) { - Ordering::Greater => vec.extend_from_slice(&buf[0..num as _]), - _ => break, - } + encoded + }; + + let mut url = base_url.to_string(); + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + + let mut first = true; + for (key, value) in params { + if !first { + url.push('&'); + } + first = false; + url.push_str(&url_encode(key)); + url.push('='); + url.push_str(&url_encode(value)); + } + + url +} + +impl MultipartField { + pub fn text, V: Into>(name: N, value: V) -> Self { + Self { + name: name.into(), + value: MultipartValue::Text(value.into()), } - String::from_utf8(vec).map_err(|_| HttpErrorKind::Utf8Error) } - pub fn close(self) { - unsafe { - http_close(self.inner); + pub fn binary>( + name: N, + data: Vec, + filename: Option, + content_type: Option, + ) -> Self { + Self { + name: name.into(), + value: MultipartValue::Binary { + data, + filename, + content_type, + }, } } - pub fn read_body(&self, buf: &mut [u8]) -> Result { - let mut num: u32 = 0; - let rs = unsafe { http_read_body(self.inner, buf.as_mut_ptr(), buf.len() as _, &mut num) }; - if rs != 0 { - return Err(HttpErrorKind::from(rs)); + pub fn file, F: Into>( + name: N, + data: Vec, + filename: F, + content_type: Option, + ) -> Self { + Self { + name: name.into(), + value: MultipartValue::Binary { + data, + filename: Some(filename.into()), + content_type, + }, } - Ok(num) } } -impl Drop for BlocklessHttp { - fn drop(&mut self) { - unsafe { - http_close(self.inner); +// Module-level convenience functions (like reqwest) +pub fn get>(url: U) -> RequestBuilder { + HttpClient::new().get(url) +} + +pub fn post>(url: U) -> RequestBuilder { + HttpClient::new().post(url) +} + +pub fn put>(url: U) -> RequestBuilder { + HttpClient::new().put(url) +} + +pub fn patch>(url: U) -> RequestBuilder { + HttpClient::new().patch(url) +} + +pub fn delete>(url: U) -> RequestBuilder { + HttpClient::new().delete(url) +} + +pub fn head>(url: U) -> RequestBuilder { + HttpClient::new().head(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_array_body() { + let json_str = r#"{"success":true,"data":{"status":200,"headers":{"content-type":"application/json"},"body":[123,34,104,101,108,108,111,34,58,34,119,111,114,108,100,34,125],"url":"https://httpbin.org/get"}}"#; + + let result: HttpResult = serde_json::from_str(json_str).unwrap(); + assert!(result.success); + + let response = result.data.unwrap(); + assert_eq!(response.status, 200); + + // The body should be: {"hello":"world"} + let expected_body = b"{\"hello\":\"world\"}"; + assert_eq!(response.body, expected_body); + + let body_text = response.text().unwrap(); + assert_eq!(body_text, "{\"hello\":\"world\"}"); + } + + #[test] + fn test_multipart_field_creation() { + let text_field = MultipartField::text("name", "value"); + assert_eq!(text_field.name, "name"); + match text_field.value { + MultipartValue::Text(ref v) => assert_eq!(v, "value"), + _ => panic!("Expected text value"), + } + + let binary_field = + MultipartField::binary("file", vec![1, 2, 3], Some("test.bin".to_string()), None); + assert_eq!(binary_field.name, "file"); + match binary_field.value { + MultipartValue::Binary { + ref data, + ref filename, + .. + } => { + assert_eq!(data, &vec![1, 2, 3]); + assert_eq!(filename.as_ref().unwrap(), "test.bin"); + } + _ => panic!("Expected binary value"), } } + + #[test] + fn test_url_building() { + let mut params = HashMap::new(); + params.insert("key1".to_string(), "value1".to_string()); + params.insert("key2".to_string(), "value with spaces".to_string()); + + let url = build_url_with_params("https://example.com/api", ¶ms); + assert!(url.contains("key1=value1")); + assert!(url.contains("key2=value%20with%20spaces")); + assert!(url.starts_with("https://example.com/api?")); + } + + #[test] + fn test_client_builder() { + let mut headers = HashMap::new(); + headers.insert("User-Agent".to_string(), "Blockless-SDK/1.0".to_string()); + + let client = HttpClient::builder() + .default_headers(headers) + .timeout(10000) + .build(); + + assert!(client.default_headers.is_some()); + assert_eq!(client.timeout, Some(10000)); + } + + #[test] + fn test_request_builder() { + let client = HttpClient::new(); + let request = client + .post("https://httpbin.org/post") + .header("Content-Type", "application/json") + .query("search", "test") + .query("limit", "10") + .body("test body") + .timeout(5000); + + assert_eq!(request.method, "POST"); + assert_eq!(request.url, "https://httpbin.org/post"); + assert_eq!( + request.headers.get("Content-Type").unwrap(), + "application/json" + ); + assert_eq!(request.query_params.get("search").unwrap(), "test"); + assert_eq!(request.query_params.get("limit").unwrap(), "10"); + assert_eq!(request.timeout, Some(5000)); + + match request.body.as_ref().unwrap() { + HttpBody::Text(ref body) => assert_eq!(body, "test body"), + _ => panic!("Expected text body"), + } + } + + #[test] + fn test_basic_auth() { + let client = HttpClient::new(); + let request = client + .get("https://httpbin.org/basic-auth/user/pass") + .basic_auth("username", "password"); + + let auth_header = request.headers.get("Authorization").unwrap(); + assert!(auth_header.starts_with("Basic ")); + + // Verify it's properly base64 encoded "username:password" + let encoded_part = &auth_header[6..]; // Remove "Basic " prefix + let decoded = base64::decode_config(encoded_part, base64::STANDARD).unwrap(); + let decoded_str = String::from_utf8(decoded).unwrap(); + assert_eq!(decoded_str, "username:password"); + } + + #[test] + fn test_bearer_auth() { + let client = HttpClient::new(); + let request = client + .get("https://httpbin.org/bearer") + .bearer_auth("test-token-123"); + + let auth_header = request.headers.get("Authorization").unwrap(); + assert_eq!(auth_header, "Bearer test-token-123"); + } + + #[test] + fn test_query_params_integration() { + let mut params1 = HashMap::new(); + params1.insert("base".to_string(), "param".to_string()); + + let client = HttpClient::new(); + let request = client + .get("https://api.example.com/search") + .query_params(params1) + .query("additional", "value") + .query("special chars", "test & encode"); + + assert_eq!(request.query_params.get("base").unwrap(), "param"); + assert_eq!(request.query_params.get("additional").unwrap(), "value"); + assert_eq!( + request.query_params.get("special chars").unwrap(), + "test & encode" + ); + + // Test URL building + let url = build_url_with_params("https://api.example.com/search", &request.query_params); + assert!(url.contains("base=param")); + assert!(url.contains("additional=value")); + assert!(url.contains("special%20chars=test%20%26%20encode")); + } + + #[test] + fn test_module_level_functions() { + // Test that module-level convenience functions work + let _get_request = get("https://httpbin.org/get"); + let _post_request = post("https://httpbin.org/post"); + let _put_request = put("https://httpbin.org/put"); + let _patch_request = patch("https://httpbin.org/patch"); + let _delete_request = delete("https://httpbin.org/delete"); + + // These should all return RequestBuilder objects + let request = get("https://httpbin.org/get") + .query("test", "value") + .header("User-Agent", "test"); + + assert_eq!(request.method, "GET"); + assert_eq!(request.url, "https://httpbin.org/get"); + assert_eq!(request.query_params.get("test").unwrap(), "value"); + assert_eq!(request.headers.get("User-Agent").unwrap(), "test"); + } } From e0d65726ed63238031fde81314113dd4063ee0cf Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:28:51 +1200 Subject: [PATCH 03/17] export new http client --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b60c611..11d4250 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,15 @@ mod bless_crawl; mod cgi; mod error; -mod http; mod llm; mod memory; mod socket; +pub mod http; + pub use bless_crawl::*; pub use cgi::*; pub use error::*; -pub use http::*; pub use llm::*; pub use memory::*; pub use socket::*; From 80a244e096a6410f81698c2744c37d845290f3f2 Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:29:11 +1200 Subject: [PATCH 04/17] add new http client example --- examples/http_client.rs | 175 ++++++++++++++++++++++++++++++++++++++++ examples/httpbin.rs | 26 ------ 2 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 examples/http_client.rs delete mode 100644 examples/httpbin.rs diff --git a/examples/http_client.rs b/examples/http_client.rs new file mode 100644 index 0000000..d2ebdae --- /dev/null +++ b/examples/http_client.rs @@ -0,0 +1,175 @@ +use blockless_sdk::http::{get, post, HttpClient, MultipartField}; +use std::collections::HashMap; + +fn main() -> Result<(), Box> { + println!("===================================="); + println!("HTTP v2 Client Demo"); + println!("===================================="); + + println!("\n1. GET request:"); + match get("https://httpbin.org/get").send() { + Ok(response) => { + println!("GET Status: {}", response.status()); + println!("GET Success: {}", response.is_success()); + } + Err(e) => println!("GET Error: {}", e), + } + + println!("\n2. POST with JSON:"); + let json_data = serde_json::json!({ + "name": "Blockless SDK", + "version": "2.0", + "api_style": "reqwest-like" + }); + match post("https://httpbin.org/post").json(&json_data)?.send() { + Ok(response) => { + println!("POST JSON Status: {}", response.status()); + if let Ok(response_json) = response.json::() { + if let Some(received_json) = response_json.get("json") { + println!("Received JSON: {}", received_json); + } + } + } + Err(e) => println!("POST JSON Error: {}", e), + } + + println!("\n3. Client instance with default configuration:"); + let mut default_headers = HashMap::new(); + default_headers.insert("User-Agent".to_string(), "Blockless-SDK/2.0".to_string()); + default_headers.insert("Accept".to_string(), "application/json".to_string()); + let client = HttpClient::builder() + .default_headers(default_headers) + .timeout(10000) + .build(); + match client + .get("https://httpbin.org/get") + .query("search", "blockless") + .query("limit", "10") + .query("format", "json") + .send() + { + Ok(response) => { + println!("Client GET Status: {}", response.status()); + if let Ok(json_data) = response.json::() { + if let Some(args) = json_data.get("args") { + println!("Query params: {}", args); + } + } + } + Err(e) => println!("Client GET Error: {}", e), + } + + println!("\n4. Authentication examples:"); + match client + .get("https://httpbin.org/basic-auth/user/pass") + .basic_auth("user", "pass") + .send() + { + Ok(response) => { + println!("Basic auth status: {}", response.status()); + if let Ok(json_data) = response.json::() { + println!("Authenticated: {:?}", json_data.get("authenticated")); + } + } + Err(e) => println!("Basic auth error: {}", e), + } + + match client + .get("https://httpbin.org/bearer") + .bearer_auth("test-token-12345") + .send() + { + Ok(response) => { + println!("Bearer auth status: {}", response.status()); + if let Ok(json_data) = response.json::() { + println!("Token received: {:?}", json_data.get("token")); + } + } + Err(e) => println!("Bearer auth error: {}", e), + } + + println!("\n5. Different request body types:"); + let mut form_data = HashMap::new(); + form_data.insert("name".to_string(), "Blockless".to_string()); + form_data.insert("type".to_string(), "distributed computing".to_string()); + match client + .post("https://httpbin.org/post") + .form(form_data) + .send() + { + Ok(response) => { + println!("Form POST Status: {}", response.status()); + if let Ok(json_data) = response.json::() { + if let Some(form) = json_data.get("form") { + println!("Form data received: {}", form); + } + } + } + Err(e) => println!("Form POST Error: {}", e), + } + + println!("\n6. Multipart form with file upload:"); + let multipart_fields = vec![ + MultipartField::text("description", "SDK test file"), + MultipartField::file( + "upload", + b"Hello from Blockless SDK v2!".to_vec(), + "hello.txt", + Some("text/plain".to_string()), + ), + ]; + match client + .post("https://httpbin.org/post") + .multipart(multipart_fields) + .send() + { + Ok(response) => { + println!("Multipart POST Status: {}", response.status()); + if let Ok(json_data) = response.json::() { + if let Some(files) = json_data.get("files") { + println!("Files uploaded: {}", files); + } + } + } + Err(e) => println!("Multipart POST Error: {}", e), + } + + println!("\n7. Binary data:"); + let binary_data = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]; // "Hello" in bytes + match client + .post("https://httpbin.org/post") + .header("Content-Type", "application/octet-stream") + .body_bytes(binary_data) + .send() + { + Ok(response) => { + println!("Binary POST Status: {}", response.status()); + } + Err(e) => println!("Binary POST Error: {}", e), + } + + println!("\n8. Advanced request building:"); + match client + .put("https://httpbin.org/put") + .header("X-Custom-Header", "custom-value") + .header("X-API-Version", "2.0") + .query("action", "update") + .query("id", "12345") + .timeout(5000) + .body("Updated data") + .send() + { + Ok(response) => { + println!("PUT Status: {}", response.status()); + if let Ok(json_data) = response.json::() { + if let Some(headers) = json_data.get("headers") { + println!("Custom headers received: {}", headers); + } + } + } + Err(e) => println!("PUT Error: {}", e), + } + + println!("\nDemo completed ๐Ÿš€"); + Ok(()) +} diff --git a/examples/httpbin.rs b/examples/httpbin.rs deleted file mode 100644 index 7863b89..0000000 --- a/examples/httpbin.rs +++ /dev/null @@ -1,26 +0,0 @@ -use blockless_sdk::*; - -fn main() { - let mut opts = HttpOptions::new("GET", 30, 10); - opts.headers = Some(std::collections::BTreeMap::from([( - "X-Test".to_string(), - "123".to_string(), - )])); - - let http = BlocklessHttp::open("http://httpbin.org/anything", &opts); - let http = http.unwrap(); - let body = http.get_all_body().unwrap(); - let body = String::from_utf8(body).unwrap(); - let bodies = match json::parse(&body).unwrap() { - json::JsonValue::Object(o) => o, - _ => panic!("must be object"), - }; - - let headers = match bodies.get("headers") { - Some(json::JsonValue::Object(headers)) => headers, - _ => panic!("must be array"), - }; - headers.iter().for_each(|s| { - println!("{} = {}", s.0, s.1); - }); -} From 3318ae76fe9e0e9b11445d2643c7f9777a2b869b Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:29:27 +1200 Subject: [PATCH 05/17] update oracle example --- examples/coingecko_oracle.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/examples/coingecko_oracle.rs b/examples/coingecko_oracle.rs index f5707ec..09338fc 100644 --- a/examples/coingecko_oracle.rs +++ b/examples/coingecko_oracle.rs @@ -1,4 +1,5 @@ -use blockless_sdk::*; +use blockless_sdk::http::HttpClient; +use blockless_sdk::read_stdin; use serde_json::json; use std::collections::HashMap; @@ -19,17 +20,13 @@ fn main() { .trim(); // perform http request - let http_opts = HttpOptions::new("GET", 30, 10); - let http_res = BlocklessHttp::open( - format!( - "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies=usd", - coin_id - ) - .as_str(), - &http_opts, - ) - .unwrap(); - let body = http_res.get_all_body().unwrap(); // e.g. {"bitcoin":{"usd":67675}} + let client = HttpClient::new(); + let url = format!( + "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies=usd", + coin_id + ); + let response = client.get(&url).send().unwrap(); + let body = response.bytes().to_vec(); // e.g. {"bitcoin":{"usd":67675}} // println!("{}", String::from_utf8(body.clone()).unwrap()); From 40b44a4987fab6612d5754556d5b1a0a7868cc12 Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:50:46 +1200 Subject: [PATCH 06/17] readme upd --- README.md | 38 ++++++-------------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 46a799e..93fcc8a 100644 --- a/README.md +++ b/README.md @@ -9,33 +9,7 @@ 2. Use follow command for build the project. ```bash -$ cargo build -``` - -HTTP example - -```rust -use blockless_sdk::*; -use json; - -fn main() { - let opts = HttpOptions::new("GET", 30, 10); - let http = BlocklessHttp::open("https://demo.bls.dev/tokens", &opts); - let http = http.unwrap(); - let body = http.get_all_body().unwrap(); - let body = String::from_utf8(body).unwrap(); - let tokens = match json::parse(&body).unwrap() { - json::JsonValue::Object(o) => o, - _ => panic!("must be object"), - }; - let tokens = match tokens.get("tokens") { - Some(json::JsonValue::Array(tokens)) => tokens, - _ => panic!("must be array"), - }; - tokens.iter().for_each(|s| { - println!("{:?}", s.as_str()); - }); -} +cargo build --release --target wasm32-wasip1 ``` ## Install from [crates.io](https://crates.io/crates/blockless-sdk) @@ -58,14 +32,14 @@ cargo build --release --target wasm32-wasip1 --example coingecko_oracle echo "bitcoin" | runtime target/wasm32-wasip1/release/examples/coingecko_oracle.wasm --permission https://api.coingecko.com/ ``` -### [HTTP](./examples/httpbin.rs) +### [HTTP](./examples/http_client.rs) ```sh # Build example -cargo build --release --target wasm32-wasip1 --example httpbin +cargo build --release --target wasm32-wasip1 --example http_client # Run example with blockless runtime -~/.bls/runtime/bls-runtime target/wasm32-wasip1/release/examples/httpbin.wasm --permission http://httpbin.org/anything +~/.bls/runtime/bls-runtime target/wasm32-wasip1/release/examples/http_client.wasm --permission http://httpbin.org/anything ``` ### [LLM-MCP](./examples/llm-mcp.rs) @@ -83,8 +57,8 @@ cargo build --release --target wasm32-wasip1 --example llm-mcp | Example | Description | [Browser runtime](https://github.com/blocklessnetwork/b7s-browser) support | [Native runtime](https://github.com/blessnetwork/bls-runtime) support | | ------- | ----------- | --------------- | --------------- | -| [coingecko_oracle](./examples/coingecko_oracle.rs) | Coingecko Oracle to query price of bitcoin from coingecko | โœ… | โœ… | -| [httpbin](./examples/httpbin.rs) | HTTP to query anything from httpbin | โœ… | โœ… | +| [coingecko_oracle](./examples/coingecko_oracle.rs) | Coingecko Oracle to query price of bitcoin from coingecko | โœ… | โŒ | +| [http_client](./examples/http_client.rs) | HTTP client demonstrating various request types (GET, POST, auth, multipart) | โœ… | โŒ | | [llm](./examples/llm.rs) | LLM to chat with `Llama-3.1-8B-Instruct-q4f32_1-MLC` and `SmolLM2-1.7B-Instruct-q4f16_1-MLC` models | โœ… | โœ… | | [llm-mcp](./examples/llm-mcp.rs) | LLM with MCP (Model Control Protocol) demonstrating tool integration using SSE endpoints | โœ… | โœ… | | [web-scrape](./examples/web-scrape.rs) | Web Scraping to scrape content from a single URL with custom configuration overrides | โœ… | โŒ | From 6274071e09831e6c8313d6eba6740487f0caac20 Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 16:54:57 +1200 Subject: [PATCH 07/17] removed redundant types --- src/http.rs | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/http.rs b/src/http.rs index 76ddc04..d8f0a30 100644 --- a/src/http.rs +++ b/src/http.rs @@ -298,22 +298,19 @@ impl RequestBuilder { self } - pub fn json(mut self, json: &T) -> Result { - let json_body = serde_json::to_string(json).map_err(|_| Error::SerializationError)?; + pub fn json(mut self, json: &T) -> Result { + let json_body = serde_json::to_string(json).map_err(|_| HttpError::SerializationError)?; self.body = Some(HttpBody::Text(json_body)); self.headers .insert("Content-Type".to_string(), "application/json".to_string()); Ok(self) } - pub fn send(self) -> Result { + pub fn send(self) -> Result { self.client.execute(&self) } } -pub type Response = HttpResponse; -pub type Error = HttpError; - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct HttpResponse { pub status: u16, @@ -441,7 +438,7 @@ impl HttpClient { } } - fn execute(&self, builder: &RequestBuilder) -> Result { + fn execute(&self, builder: &RequestBuilder) -> Result { let options = HttpOptions { method: Some(builder.method.clone()), headers: if builder.headers.is_empty() { @@ -461,11 +458,11 @@ impl HttpClient { self.make_request(&builder.url, options) } - fn make_request(&self, url: &str, options: HttpOptions) -> Result { + fn make_request(&self, url: &str, options: HttpOptions) -> Result { const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response if url.is_empty() { - return Err(Error::InvalidUrl); + return Err(HttpError::InvalidUrl); } // Build final URL with query parameters @@ -476,7 +473,7 @@ impl HttpClient { }; let options_json = - serde_json::to_string(&options).map_err(|_| Error::SerializationError)?; + serde_json::to_string(&options).map_err(|_| HttpError::SerializationError)?; let mut result_buffer = vec![0u8; MAX_RESPONSE_SIZE]; let mut bytes_written: u32 = 0; @@ -494,26 +491,26 @@ impl HttpClient { }; if exit_code != 0 { - return Err(Error::from_code(exit_code)); + return Err(HttpError::from_code(exit_code)); } if bytes_written == 0 { - return Err(Error::EmptyResponse); + return Err(HttpError::EmptyResponse); } let response_bytes = &result_buffer[..bytes_written as usize]; let http_result: HttpResult = - serde_json::from_slice(response_bytes).map_err(|_| Error::JsonParseError)?; + serde_json::from_slice(response_bytes).map_err(|_| HttpError::JsonParseError)?; if !http_result.success { let error_msg = http_result .error .unwrap_or_else(|| "Unknown error".to_string()); - return Err(Error::RequestFailed(error_msg)); + return Err(HttpError::RequestFailed(error_msg)); } - http_result.data.ok_or(Error::EmptyResponse) + http_result.data.ok_or(HttpError::EmptyResponse) } } From 368b6b8179033b1b7daeed78f42ae84373bf694e Mon Sep 17 00:00:00 2001 From: z Date: Tue, 8 Jul 2025 17:58:46 +1200 Subject: [PATCH 08/17] updated url encoding --- src/http.rs | 120 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 74 insertions(+), 46 deletions(-) diff --git a/src/http.rs b/src/http.rs index d8f0a30..3e0668b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::fmt::Write; type ExitCode = u32; @@ -67,22 +66,6 @@ pub enum HttpBody { Multipart(Vec), } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -pub struct MultipartField { - pub name: String, - pub value: MultipartValue, -} - -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -pub enum MultipartValue { - Text(String), - Binary { - data: Vec, - filename: Option, - content_type: Option, - }, -} - impl Default for HttpOptions { fn default() -> Self { Self { @@ -561,38 +544,48 @@ pub fn build_url_with_params(base_url: &str, params: &HashMap) - if params.is_empty() { return base_url.to_string(); } - - let url_encode = |s: &str| -> String { - let mut encoded = String::new(); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - _ => { - write!(&mut encoded, "%{:02X}", byte).unwrap(); - } + match url::Url::parse(base_url) { + Ok(mut url) => { + for (key, value) in params { + url.query_pairs_mut().append_pair(key, value); } + url.to_string() } - encoded - }; - - let mut url = base_url.to_string(); - let separator = if url.contains('?') { '&' } else { '?' }; - url.push(separator); - - let mut first = true; - for (key, value) in params { - if !first { - url.push('&'); + Err(_) => { + // Fallback for invalid URLs - append query parameters manually + let mut url = base_url.to_string(); + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + + let encoded_params: Vec = params + .iter() + .map(|(k, v)| { + format!( + "{}={}", + url::form_urlencoded::byte_serialize(k.as_bytes()).collect::(), + url::form_urlencoded::byte_serialize(v.as_bytes()).collect::() + ) + }) + .collect(); + url.push_str(&encoded_params.join("&")); + url } - first = false; - url.push_str(&url_encode(key)); - url.push('='); - url.push_str(&url_encode(value)); } +} - url +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum MultipartValue { + Text(String), + Binary { + data: Vec, + filename: Option, + content_type: Option, + }, +} +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct MultipartField { + pub name: String, + pub value: MultipartValue, } impl MultipartField { @@ -716,10 +709,45 @@ mod tests { let url = build_url_with_params("https://example.com/api", ¶ms); assert!(url.contains("key1=value1")); - assert!(url.contains("key2=value%20with%20spaces")); + assert!(url.contains("key2=value+with+spaces")); assert!(url.starts_with("https://example.com/api?")); } + #[test] + fn test_url_building_special_chars() { + let mut params = HashMap::new(); + params.insert("special".to_string(), "!@#$%^&*()".to_string()); + params.insert("utf8".to_string(), "ใ“ใ‚“ใซใกใฏ".to_string()); + params.insert("reserved".to_string(), "test&foo=bar".to_string()); + + let url = build_url_with_params("https://example.com/api", ¶ms); + + // Check that special characters are properly encoded + // Note: url crate uses + for spaces and different encoding for some chars + assert!(url.contains("special=%21%40%23%24%25%5E%26*%28%29")); + assert!(url.contains("reserved=test%26foo%3Dbar")); + // UTF-8 characters should be percent-encoded + assert!(url.contains("utf8=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF")); + } + + #[test] + fn test_url_building_with_existing_query() { + let mut params = HashMap::new(); + params.insert("new_param".to_string(), "new_value".to_string()); + + let url = build_url_with_params("https://example.com/api?existing=param", ¶ms); + assert!(url.contains("existing=param")); + assert!(url.contains("new_param=new_value")); + assert!(url.contains("&")); + } + + #[test] + fn test_url_building_empty_params() { + let params = HashMap::new(); + let url = build_url_with_params("https://example.com/api", ¶ms); + assert_eq!(url, "https://example.com/api"); + } + #[test] fn test_client_builder() { let mut headers = HashMap::new(); @@ -812,7 +840,7 @@ mod tests { let url = build_url_with_params("https://api.example.com/search", &request.query_params); assert!(url.contains("base=param")); assert!(url.contains("additional=value")); - assert!(url.contains("special%20chars=test%20%26%20encode")); + assert!(url.contains("special+chars=test+%26+encode")); } #[test] From 1bcef11b174d877bce3c997375113bb62d3a765e Mon Sep 17 00:00:00 2001 From: z Date: Wed, 9 Jul 2025 03:19:25 +1200 Subject: [PATCH 09/17] updated http module --- src/http.rs | 452 ++++++++++++++++++++++++++-------------------------- 1 file changed, 229 insertions(+), 223 deletions(-) diff --git a/src/http.rs b/src/http.rs index 3e0668b..e3dd663 100644 --- a/src/http.rs +++ b/src/http.rs @@ -43,6 +43,17 @@ mod mock_ffi { #[cfg(feature = "mock-ffi")] use mock_ffi::*; +const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(untagged)] +pub enum HttpBody { + Text(String), + Binary(Vec), + Form(HashMap), + Multipart(Vec), +} + #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct HttpOptions { #[serde(skip_serializing_if = "Option::is_none")] @@ -57,15 +68,6 @@ pub struct HttpOptions { pub query_params: Option>, } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -#[serde(untagged)] -pub enum HttpBody { - Text(String), - Binary(Vec), - Form(HashMap), - Multipart(Vec), -} - impl Default for HttpOptions { fn default() -> Self { Self { @@ -165,132 +167,60 @@ impl HttpOptions { } } -pub struct HttpClientBuilder { - default_headers: Option>, - timeout: Option, +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub enum MultipartValue { + Text(String), + Binary { + data: Vec, + filename: Option, + content_type: Option, + }, } -impl Default for HttpClientBuilder { - fn default() -> Self { - Self::new() - } +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct MultipartField { + pub name: String, + pub value: MultipartValue, } -impl HttpClientBuilder { - pub fn new() -> Self { +impl MultipartField { + pub fn text, V: Into>(name: N, value: V) -> Self { Self { - default_headers: None, - timeout: Some(30000), - } - } - - pub fn default_headers(mut self, headers: HashMap) -> Self { - self.default_headers = Some(headers); - self - } - - pub fn timeout(mut self, timeout: u32) -> Self { - self.timeout = Some(timeout); - self - } - - pub fn build(self) -> HttpClient { - HttpClient { - default_headers: self.default_headers, - timeout: self.timeout, + name: name.into(), + value: MultipartValue::Text(value.into()), } } -} -impl Clone for HttpClient { - fn clone(&self) -> Self { + pub fn binary>( + name: N, + data: Vec, + filename: Option, + content_type: Option, + ) -> Self { Self { - default_headers: self.default_headers.clone(), - timeout: self.timeout, + name: name.into(), + value: MultipartValue::Binary { + data, + filename, + content_type, + }, } } -} -impl RequestBuilder { - pub fn header, V: Into>(mut self, key: K, value: V) -> Self { - self.headers.insert(key.into(), value.into()); - self - } - - pub fn headers(mut self, headers: HashMap) -> Self { - self.headers.extend(headers); - self - } - - pub fn query, V: Into>(mut self, key: K, value: V) -> Self { - self.query_params.insert(key.into(), value.into()); - self - } - - pub fn query_params(mut self, params: HashMap) -> Self { - self.query_params.extend(params); - self - } - - pub fn basic_auth, P: Into>( - mut self, - username: U, - password: P, + pub fn file, F: Into>( + name: N, + data: Vec, + filename: F, + content_type: Option, ) -> Self { - let credentials = format!("{}:{}", username.into(), password.into()); - let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD); - self.headers - .insert("Authorization".to_string(), format!("Basic {}", encoded)); - self - } - - pub fn bearer_auth>(mut self, token: T) -> Self { - self.headers.insert( - "Authorization".to_string(), - format!("Bearer {}", token.into()), - ); - self - } - - pub fn timeout(mut self, timeout: u32) -> Self { - self.timeout = Some(timeout); - self - } - - pub fn body>(mut self, body: S) -> Self { - self.body = Some(HttpBody::Text(body.into())); - self - } - - pub fn body_bytes(mut self, body: Vec) -> Self { - self.body = Some(HttpBody::Binary(body)); - self - } - - pub fn form(mut self, form: HashMap) -> Self { - self.body = Some(HttpBody::Form(form)); - self.headers.insert( - "Content-Type".to_string(), - "application/x-www-form-urlencoded".to_string(), - ); - self - } - - pub fn multipart(mut self, form: Vec) -> Self { - self.body = Some(HttpBody::Multipart(form)); - self - } - - pub fn json(mut self, json: &T) -> Result { - let json_body = serde_json::to_string(json).map_err(|_| HttpError::SerializationError)?; - self.body = Some(HttpBody::Text(json_body)); - self.headers - .insert("Content-Type".to_string(), "application/json".to_string()); - Ok(self) - } - - pub fn send(self) -> Result { - self.client.execute(&self) + Self { + name: name.into(), + value: MultipartValue::Binary { + data, + filename: Some(filename.into()), + content_type, + }, + } } } @@ -346,18 +276,50 @@ pub struct HttpResult { pub error: Option, } -pub struct HttpClient { - default_headers: Option>, - timeout: Option, +#[derive(Debug, Clone)] +pub enum HttpError { + InvalidUrl, + SerializationError, + JsonParseError, + Utf8Error, + EmptyResponse, + RequestFailed(String), + NetworkError, + Timeout, + Unknown(u32), } -pub struct RequestBuilder { - client: HttpClient, - method: String, - url: String, - headers: HashMap, - query_params: HashMap, - body: Option, +impl HttpError { + fn from_code(code: u32) -> Self { + match code { + 1 => HttpError::InvalidUrl, + 2 => HttpError::Timeout, + 3 => HttpError::NetworkError, + _ => HttpError::Unknown(code), + } + } +} + +impl std::fmt::Display for HttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HttpError::InvalidUrl => write!(f, "Invalid URL provided"), + HttpError::SerializationError => write!(f, "Failed to serialize request data"), + HttpError::JsonParseError => write!(f, "Failed to parse JSON response"), + HttpError::Utf8Error => write!(f, "Invalid UTF-8 in response"), + HttpError::EmptyResponse => write!(f, "Empty response received"), + HttpError::RequestFailed(msg) => write!(f, "Request failed: {}", msg), + HttpError::NetworkError => write!(f, "Network error occurred"), + HttpError::Timeout => write!(f, "Request timed out"), + HttpError::Unknown(code) => write!(f, "Unknown error (code: {})", code), + } + } +} + +impl std::error::Error for HttpError {} + +pub struct HttpClient { + default_headers: Option>, timeout: Option, } @@ -367,6 +329,15 @@ impl Default for HttpClient { } } +impl Clone for HttpClient { + fn clone(&self) -> Self { + Self { + default_headers: self.default_headers.clone(), + timeout: self.timeout, + } + } +} + impl HttpClient { pub fn new() -> Self { Self { @@ -442,8 +413,6 @@ impl HttpClient { } fn make_request(&self, url: &str, options: HttpOptions) -> Result { - const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response - if url.is_empty() { return Err(HttpError::InvalidUrl); } @@ -497,49 +466,139 @@ impl HttpClient { } } -#[derive(Debug, Clone)] -pub enum HttpError { - InvalidUrl, - SerializationError, - JsonParseError, - Utf8Error, - EmptyResponse, - RequestFailed(String), - NetworkError, - Timeout, - Unknown(u32), +pub struct HttpClientBuilder { + default_headers: Option>, + timeout: Option, } -impl HttpError { - fn from_code(code: u32) -> Self { - match code { - 1 => HttpError::InvalidUrl, - 2 => HttpError::Timeout, - 3 => HttpError::NetworkError, - _ => HttpError::Unknown(code), - } +impl Default for HttpClientBuilder { + fn default() -> Self { + Self::new() } } -impl std::fmt::Display for HttpError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HttpError::InvalidUrl => write!(f, "Invalid URL provided"), - HttpError::SerializationError => write!(f, "Failed to serialize request data"), - HttpError::JsonParseError => write!(f, "Failed to parse JSON response"), - HttpError::Utf8Error => write!(f, "Invalid UTF-8 in response"), - HttpError::EmptyResponse => write!(f, "Empty response received"), - HttpError::RequestFailed(msg) => write!(f, "Request failed: {}", msg), - HttpError::NetworkError => write!(f, "Network error occurred"), - HttpError::Timeout => write!(f, "Request timed out"), - HttpError::Unknown(code) => write!(f, "Unknown error (code: {})", code), +impl HttpClientBuilder { + pub fn new() -> Self { + Self { + default_headers: None, + timeout: Some(30000), } } + + pub fn default_headers(mut self, headers: HashMap) -> Self { + self.default_headers = Some(headers); + self + } + + pub fn timeout(mut self, timeout: u32) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn build(self) -> HttpClient { + HttpClient { + default_headers: self.default_headers, + timeout: self.timeout, + } + } +} +pub struct RequestBuilder { + client: HttpClient, + method: String, + url: String, + headers: HashMap, + query_params: HashMap, + body: Option, + timeout: Option, } -impl std::error::Error for HttpError {} +impl RequestBuilder { + pub fn header, V: Into>(mut self, key: K, value: V) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers.extend(headers); + self + } + + pub fn query, V: Into>(mut self, key: K, value: V) -> Self { + self.query_params.insert(key.into(), value.into()); + self + } + + pub fn query_params(mut self, params: HashMap) -> Self { + self.query_params.extend(params); + self + } + + pub fn basic_auth, P: Into>( + mut self, + username: U, + password: P, + ) -> Self { + let credentials = format!("{}:{}", username.into(), password.into()); + let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD); + self.headers + .insert("Authorization".to_string(), format!("Basic {}", encoded)); + self + } + + pub fn bearer_auth>(mut self, token: T) -> Self { + self.headers.insert( + "Authorization".to_string(), + format!("Bearer {}", token.into()), + ); + self + } + + pub fn timeout(mut self, timeout: u32) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn body>(mut self, body: S) -> Self { + self.body = Some(HttpBody::Text(body.into())); + self + } + + pub fn body_bytes(mut self, body: Vec) -> Self { + self.body = Some(HttpBody::Binary(body)); + self + } + + pub fn form(mut self, form: HashMap) -> Self { + self.body = Some(HttpBody::Form(form)); + self.headers.insert( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + self + } + + pub fn multipart(mut self, form: Vec) -> Self { + self.body = Some(HttpBody::Multipart(form)); + self + } + + pub fn json(mut self, json: &T) -> Result { + let json_body = serde_json::to_string(json).map_err(|_| HttpError::SerializationError)?; + self.body = Some(HttpBody::Text(json_body)); + self.headers + .insert("Content-Type".to_string(), "application/json".to_string()); + Ok(self) + } + + pub fn send(self) -> Result { + self.client.execute(&self) + } +} + +// ==================== +// Utility Functions +// ==================== -// Utility functions pub fn build_url_with_params(base_url: &str, params: &HashMap) -> String { if params.is_empty() { return base_url.to_string(); @@ -573,63 +632,10 @@ pub fn build_url_with_params(base_url: &str, params: &HashMap) - } } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -pub enum MultipartValue { - Text(String), - Binary { - data: Vec, - filename: Option, - content_type: Option, - }, -} -#[derive(Debug, Clone, PartialEq, serde::Serialize)] -pub struct MultipartField { - pub name: String, - pub value: MultipartValue, -} - -impl MultipartField { - pub fn text, V: Into>(name: N, value: V) -> Self { - Self { - name: name.into(), - value: MultipartValue::Text(value.into()), - } - } - - pub fn binary>( - name: N, - data: Vec, - filename: Option, - content_type: Option, - ) -> Self { - Self { - name: name.into(), - value: MultipartValue::Binary { - data, - filename, - content_type, - }, - } - } - - pub fn file, F: Into>( - name: N, - data: Vec, - filename: F, - content_type: Option, - ) -> Self { - Self { - name: name.into(), - value: MultipartValue::Binary { - data, - filename: Some(filename.into()), - content_type, - }, - } - } -} +// ==================== +// Module-level Convenience Functions +// ==================== -// Module-level convenience functions (like reqwest) pub fn get>(url: U) -> RequestBuilder { HttpClient::new().get(url) } From 2e19ca8c50181b033969210d53b16ae8118f19e0 Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:23:22 +1200 Subject: [PATCH 10/17] rpc implementation --- src/rpc.rs | 240 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 src/rpc.rs diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..e4685d0 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,240 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// FFI bindings for the new unified RPC interface +#[cfg(not(feature = "mock-ffi"))] +#[link(wasm_import_module = "bless")] +extern "C" { + #[link_name = "rpc_call"] + fn rpc_call( + request_ptr: *const u8, + request_len: u32, + response_ptr: *mut u8, + response_max_len: u32, + bytes_written: *mut u32, + ) -> u32; +} + +#[cfg(feature = "mock-ffi")] +#[allow(unused_variables)] +mod mock_ffi { + use super::*; + + pub unsafe fn rpc_call( + _request_ptr: *const u8, + _request_len: u32, + _response_ptr: *mut u8, + _response_max_len: u32, + _bytes_written: *mut u32, + ) -> u32 { + // Mock implementation for testing + 0 + } +} + +#[derive(Debug, Clone)] +pub enum RpcError { + InvalidJson, + MethodNotFound, + InvalidParams, + InternalError, + BufferTooSmall, + Utf8Error, +} + +impl std::fmt::Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RpcError::InvalidJson => write!(f, "Invalid JSON format"), + RpcError::MethodNotFound => write!(f, "Method not found"), + RpcError::InvalidParams => write!(f, "Invalid parameters"), + RpcError::InternalError => write!(f, "Internal error"), + RpcError::BufferTooSmall => write!(f, "Buffer too small"), + RpcError::Utf8Error => write!(f, "UTF-8 conversion error"), + } + } +} + +impl std::error::Error for RpcError {} + +// JSON-RPC 2.0 structures +#[derive(Serialize, Deserialize, Debug)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, + pub id: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub id: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// Unified RPC client for calling host functions +/// +/// # Example Usage +/// +/// ```rust +/// use blockless_sdk::rpc::RpcClient; +/// use serde::{Serialize, Deserialize}; +/// +/// #[derive(Serialize, Deserialize)] +/// struct HttpRequest { +/// url: String, +/// method: String, +/// } +/// +/// #[derive(Serialize, Deserialize)] +/// struct HttpResponse { +/// status: u16, +/// body: String, +/// } +/// +/// // Create client with default 4KB buffer +/// let mut client = RpcClient::new(); +/// +/// // Create client with custom buffer size (e.g., 10MB for HTTP responses) +/// let mut client = RpcClient::with_buffer_size(10 * 1024 * 1024); +/// +/// // Type-safe method call +/// let request = HttpRequest { +/// url: "https://api.example.com".to_string(), +/// method: "GET".to_string(), +/// }; +/// +/// let response: JsonRpcResponse = client.call("http.request", Some(request))?; +/// +/// // Convenience methods +/// let pong = client.ping()?; +/// let echo_result = client.echo("Hello World!")?; +/// let version = client.version()?; +/// +/// // Modify buffer size after creation +/// client.set_buffer_size(1024 * 1024); // 1MB buffer +/// ``` +pub struct RpcClient { + next_id: u32, + buffer_size: usize, +} + +impl RpcClient { + pub fn new() -> Self { + Self::with_buffer_size(4096) // Default 4KB buffer + } + + pub fn with_buffer_size(buffer_size: usize) -> Self { + Self { + next_id: 1, + buffer_size, + } + } + + pub fn set_buffer_size(&mut self, buffer_size: usize) { + self.buffer_size = buffer_size; + } + + pub fn buffer_size(&self) -> usize { + self.buffer_size + } + + pub fn call(&mut self, method: &str, params: Option

) -> Result, RpcError> { + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params, + id: self.next_id, + }; + + self.next_id += 1; + let request_bytes = serde_json::to_vec(&request) + .map_err(|_| RpcError::InvalidJson)?; + let mut response_buffer = vec![0u8; self.buffer_size]; + let mut bytes_written = 0u32; + let result = unsafe { + rpc_call( + request_bytes.as_ptr(), + request_bytes.len() as u32, + response_buffer.as_mut_ptr(), + response_buffer.len() as u32, + &mut bytes_written as *mut u32, + ) + }; + if result != 0 { + return match result { + 1 => Err(RpcError::InvalidJson), + 2 => Err(RpcError::MethodNotFound), + 3 => Err(RpcError::InvalidParams), + 4 => Err(RpcError::InternalError), + 5 => Err(RpcError::BufferTooSmall), + _ => Err(RpcError::InternalError), + }; + } + response_buffer.truncate(bytes_written as usize); + serde_json::from_slice(&response_buffer) + .map_err(|_| RpcError::InvalidJson) + } + + /// Convenience method for ping + pub fn ping(&mut self) -> Result { + let response: JsonRpcResponse = self.call("ping", None::<()>)?; + response.result.ok_or(RpcError::InternalError) + } + + /// Convenience method for echo + pub fn echo(&mut self, data: T) -> Result { + let response: JsonRpcResponse = self.call("echo", Some(data))?; + response.result.ok_or(RpcError::InternalError) + } + + /// Convenience method for getting version + pub fn version(&mut self) -> Result, RpcError> { + let response: JsonRpcResponse> = self.call("version", None::<()>)?; + response.result.ok_or(RpcError::InternalError) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rpc_request_serialization() { + let request: JsonRpcRequest<()> = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "ping".to_string(), + params: None, + id: 1, + }; + + let json_str = serde_json::to_string(&request).unwrap(); + assert!(json_str.contains("\"jsonrpc\":\"2.0\"")); + assert!(json_str.contains("\"method\":\"ping\"")); + assert!(json_str.contains("\"id\":1")); + } + + #[test] + fn test_rpc_response_deserialization() { + let json_str = r#"{"jsonrpc":"2.0","result":"pong","id":1}"#; + let response: JsonRpcResponse = serde_json::from_str(json_str).unwrap(); + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.result, Some("pong".to_string())); + assert_eq!(response.id, 1); + assert!(response.error.is_none()); + } +} From 97d206297cbfe96866256f166a5d8ff554e557d0 Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:24:20 +1200 Subject: [PATCH 11/17] Add RPC client examples demonstrating ping, echo, version retrieval, and error handling --- examples/rpc.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 examples/rpc.rs diff --git a/examples/rpc.rs b/examples/rpc.rs new file mode 100644 index 0000000..18c30a5 --- /dev/null +++ b/examples/rpc.rs @@ -0,0 +1,105 @@ +use blockless_sdk::rpc::RpcClient; +use serde::{Deserialize, Serialize}; + +fn main() -> Result<(), Box> { + let mut client = RpcClient::new(); + + // Example 1: Simple ping + println!("=== Example 1: Simple Ping ==="); + match client.ping() { + Ok(response) => println!("Ping response: {}", response), + Err(e) => println!("Ping error: {}", e), + } + + // Example 2: Echo with different data types + println!("\n=== Example 2: Echo Examples ==="); + + // Echo string + match client.echo("Hello, World!".to_string()) { + Ok(response) => println!("Echo string: {}", response), + Err(e) => println!("Echo error: {}", e), + } + + // Echo number + match client.echo(42) { + Ok(response) => println!("Echo number: {}", response), + Err(e) => println!("Echo error: {}", e), + } + + // Echo complex object + #[derive(Serialize, Deserialize, Debug)] + struct Person { + name: String, + age: u32, + } + + let person = Person { + name: "Alice".to_string(), + age: 30, + }; + + match client.echo(person) { + Ok(response) => println!("Echo person: {:?}", response), + Err(e) => println!("Echo error: {}", e), + } + + // Example 3: Get version + println!("\n=== Example 3: Get Version ==="); + match client.version() { + Ok(version) => { + println!("Version info:"); + for (key, value) in version { + println!(" {}: {}", key, value); + } + } + Err(e) => println!("Version error: {}", e), + } + + // Example 4: Generic call with custom types + println!("\n=== Example 4: Generic Call ==="); + + #[derive(Serialize, Deserialize, Debug)] + struct CustomRequest { + message: String, + count: u32, + } + + #[derive(Serialize, Deserialize, Debug)] + struct CustomResponse { + processed: String, + timestamp: u64, + } + + let request = CustomRequest { + message: "Test message".to_string(), + count: 5, + }; + + // This would fail since "custom.process" doesn't exist in our test implementation + match client.call::("custom.process", Some(request)) { + Ok(response) => { + if let Some(result) = response.result { + println!("Custom response: {:?}", result); + } else if let Some(error) = response.error { + println!("Custom error: {} (code: {})", error.message, error.code); + } + } + Err(e) => println!("Custom call error: {}", e), + } + + // Example 5: Error handling + println!("\n=== Example 5: Error Handling ==="); + + // Try calling a non-existent method + match client.call::<(), String>("nonexistent.method", None) { + Ok(response) => { + if let Some(error) = response.error { + println!("Expected error: {} (code: {})", error.message, error.code); + } + } + Err(e) => println!("Call error: {}", e), + } + + println!("\nAll examples completed!"); + Ok(()) +} \ No newline at end of file From 23a7583ef03832595d5444e3af05fe8b09cfca9d Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:24:34 +1200 Subject: [PATCH 12/17] Add rpc module to the library --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 11d4250..7328e1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod memory; mod socket; pub mod http; +pub mod rpc; pub use bless_crawl::*; pub use cgi::*; From 7bd5bd7c3898a6902cd2514b748129c15d9fcde9 Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:24:52 +1200 Subject: [PATCH 13/17] Refactor HTTP module to integrate RPC for HTTP requests, removing mock FFI and enhancing error handling with RPC error types. --- src/http.rs | 111 +++++++++++++--------------------------------------- 1 file changed, 27 insertions(+), 84 deletions(-) diff --git a/src/http.rs b/src/http.rs index e3dd663..3c6065b 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,51 +1,16 @@ use std::collections::HashMap; +use crate::rpc::{RpcClient, RpcError, JsonRpcResponse}; -type ExitCode = u32; - -#[cfg(not(feature = "mock-ffi"))] -#[link(wasm_import_module = "blockless_http")] -extern "C" { - #[link_name = "http_call"] - fn http_call( - url_ptr: *const u8, - url_len: u32, - options_ptr: *const u8, - options_len: u32, - result_ptr: *mut u8, - result_max_len: u32, - bytes_written_ptr: *mut u32, - ) -> ExitCode; -} - -#[cfg(feature = "mock-ffi")] -#[allow(unused_variables)] -mod mock_ffi { - use super::*; +const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response - pub unsafe fn http_call( - _url_ptr: *const u8, - _url_len: u32, - _options_ptr: *const u8, - _options_len: u32, - result_ptr: *mut u8, - result_max_len: u32, - bytes_written_ptr: *mut u32, - ) -> ExitCode { - let mock_response = r#"{"success":true,"data":{"status":200,"headers":{"content-type":"application/json"},"body":[123,34,104,101,108,108,111,34,58,34,119,111,114,108,100,34,125],"url":"https://httpbin.org/get"}}"#; - let response_bytes = mock_response.as_bytes(); - let write_len = std::cmp::min(response_bytes.len(), result_max_len as usize); - std::ptr::copy_nonoverlapping(response_bytes.as_ptr(), result_ptr, write_len); - *bytes_written_ptr = write_len as u32; - 0 - } +// RPC request/response structures for HTTP +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpRpcRequest { + pub url: String, + pub options: HttpOptions, } -#[cfg(feature = "mock-ffi")] -use mock_ffi::*; - -const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response - -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(untagged)] pub enum HttpBody { Text(String), @@ -54,7 +19,7 @@ pub enum HttpBody { Multipart(Vec), } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct HttpOptions { #[serde(skip_serializing_if = "Option::is_none")] pub method: Option, @@ -167,7 +132,7 @@ impl HttpOptions { } } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum MultipartValue { Text(String), Binary { @@ -177,7 +142,7 @@ pub enum MultipartValue { }, } -#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct MultipartField { pub name: String, pub value: MultipartValue, @@ -286,20 +251,10 @@ pub enum HttpError { RequestFailed(String), NetworkError, Timeout, + RpcError(RpcError), Unknown(u32), } -impl HttpError { - fn from_code(code: u32) -> Self { - match code { - 1 => HttpError::InvalidUrl, - 2 => HttpError::Timeout, - 3 => HttpError::NetworkError, - _ => HttpError::Unknown(code), - } - } -} - impl std::fmt::Display for HttpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -311,11 +266,18 @@ impl std::fmt::Display for HttpError { HttpError::RequestFailed(msg) => write!(f, "Request failed: {}", msg), HttpError::NetworkError => write!(f, "Network error occurred"), HttpError::Timeout => write!(f, "Request timed out"), + HttpError::RpcError(e) => write!(f, "RPC error: {}", e), HttpError::Unknown(code) => write!(f, "Unknown error (code: {})", code), } } } +impl From for HttpError { + fn from(e: RpcError) -> Self { + HttpError::RpcError(e) + } +} + impl std::error::Error for HttpError {} pub struct HttpClient { @@ -424,36 +386,17 @@ impl HttpClient { url.to_string() }; - let options_json = - serde_json::to_string(&options).map_err(|_| HttpError::SerializationError)?; - - let mut result_buffer = vec![0u8; MAX_RESPONSE_SIZE]; - let mut bytes_written: u32 = 0; - - let exit_code = unsafe { - http_call( - final_url.as_ptr(), - final_url.len() as u32, - options_json.as_ptr(), - options_json.len() as u32, - result_buffer.as_mut_ptr(), - MAX_RESPONSE_SIZE as u32, - &mut bytes_written, - ) + let request = HttpRpcRequest { + url: final_url, + options, }; + let mut rpc_client = RpcClient::with_buffer_size(MAX_RESPONSE_SIZE); + let response: JsonRpcResponse = rpc_client.call("http.request", Some(request))?; - if exit_code != 0 { - return Err(HttpError::from_code(exit_code)); - } - - if bytes_written == 0 { - return Err(HttpError::EmptyResponse); + if let Some(error) = response.error { + return Err(HttpError::RequestFailed(format!("RPC error: {} (code: {})", error.message, error.code))); } - - let response_bytes = &result_buffer[..bytes_written as usize]; - - let http_result: HttpResult = - serde_json::from_slice(response_bytes).map_err(|_| HttpError::JsonParseError)?; + let http_result = response.result.ok_or(HttpError::EmptyResponse)?; if !http_result.success { let error_msg = http_result From 13b2fbee547ce20f8ae9939b6b229df16ffdcb24 Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:26:04 +1200 Subject: [PATCH 14/17] Add IPFS API demo example showcasing various HTTP requests including version retrieval, file upload, and repository statistics --- examples/ipfs_api.rs | 298 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 examples/ipfs_api.rs diff --git a/examples/ipfs_api.rs b/examples/ipfs_api.rs new file mode 100644 index 0000000..37c0846 --- /dev/null +++ b/examples/ipfs_api.rs @@ -0,0 +1,298 @@ +use blockless_sdk::http::{post, HttpClient, MultipartField}; + +fn main() -> Result<(), Box> { + println!("IPFS RPC API Demo - HTTP v2 Client"); + println!("================================="); + println!("Make sure your IPFS node is running on localhost:5001"); + println!("Docker command: docker run --rm -it --name ipfs_host -p 4001:4001 -p 4001:4001/udp -p 8080:8080 -p 5001:5001 ipfs/kubo"); + println!("Note: If you get CORS errors, configure CORS with:"); + println!(" docker exec ipfs_host ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '[\"*\"]'"); + println!(" docker exec ipfs_host ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '[\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"]'"); + println!(" docker restart ipfs_host\n"); + + // Create HTTP client configured for IPFS RPC API + let client = HttpClient::builder() + .timeout(30000) // 30 seconds for file operations + .build(); + + let ipfs_api_base = "http://localhost:5001/api/v0"; + + // Example 1: Simple POST request - Get node version + println!("1. GET node version (POST with no body):"); + match client.post(format!("{}/version", ipfs_api_base)).send() { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(version_info) = response.json::() { + println!( + " IPFS Version: {}", + version_info + .get("Version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ); + println!( + " Commit: {}", + version_info + .get("Commit") + .and_then(|c| c.as_str()) + .unwrap_or("unknown") + ); + } + } + } + Err(e) => println!(" Error: {} (Is IPFS running?)", e), + } + + // Example 2: POST with query parameters - Get node ID + println!("\n2. GET node ID (POST with query parameters):"); + match client + .post(format!("{}/id", ipfs_api_base)) + .query("format", "json") + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(id_info) = response.json::() { + println!( + " Node ID: {}", + id_info + .get("ID") + .and_then(|id| id.as_str()) + .unwrap_or("unknown") + ); + if let Some(addresses) = id_info.get("Addresses") { + if let Some(addr_array) = addresses.as_array() { + if !addr_array.is_empty() { + println!(" First Address: {}", addr_array[0]); + } + } + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + // Example 3: POST with multipart file upload - Add file to IPFS + println!("\n3. ADD file to IPFS (POST with multipart upload):"); + let file_content = b"Hello from Blockless SDK HTTP v2 client!\nThis file was uploaded to IPFS using multipart form data."; + let multipart_fields = vec![MultipartField::file( + "file", // IPFS expects 'file' as the field name + file_content.to_vec(), + "hello-blockless.txt", + Some("text/plain".to_string()), + )]; + + match client + .post(format!("{}/add", ipfs_api_base)) + .query("pin", "true") // Pin the file after adding + .multipart(multipart_fields) + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(add_result) = response.json::() { + let hash = add_result + .get("Hash") + .and_then(|h| h.as_str()) + .unwrap_or("unknown"); + let name = add_result + .get("Name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let size = add_result + .get("Size") + .and_then(|s| s.as_str()) + .unwrap_or("0"); + println!(" Added file: {}", name); + println!(" IPFS Hash: {}", hash); + println!(" Size: {} bytes", size); + + // Store hash for later examples + if hash != "unknown" { + demonstrate_file_operations(&client, ipfs_api_base, hash)?; + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + // Example 4: Repository stats (POST with query parameters) + println!("\n4. GET repository statistics (POST with boolean parameters):"); + match client + .post(format!("{}/repo/stat", ipfs_api_base)) + .query("human", "true") + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(repo_stats) = response.json::() { + println!( + " Repo Size: {}", + repo_stats + .get("RepoSize") + .unwrap_or(&serde_json::Value::Number(0.into())) + ); + println!( + " Storage Max: {}", + repo_stats + .get("StorageMax") + .unwrap_or(&serde_json::Value::Number(0.into())) + ); + println!( + " Num Objects: {}", + repo_stats + .get("NumObjects") + .unwrap_or(&serde_json::Value::Number(0.into())) + ); + } + } + } + Err(e) => println!(" Error: {}", e), + } + + // Example 5: Pin operations - List pinned objects + println!("\n5. LIST pinned objects (POST with type filter):"); + match client + .post(format!("{}/pin/ls", ipfs_api_base)) + .query("type", "recursive") + .query("stream", "true") + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(pin_list) = response.json::() { + if let Some(keys) = pin_list.get("Keys").and_then(|k| k.as_object()) { + println!(" Pinned objects count: {}", keys.len()); + // Show first few pinned objects + for (hash, info) in keys.iter().take(3) { + if let Some(pin_type) = info.get("Type") { + println!(" - {} ({})", hash, pin_type); + } + } + if keys.len() > 3 { + println!(" ... and {} more", keys.len() - 3); + } + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + // Example 6: Module-level convenience function + println!("\n6. GET swarm peers (using module-level function):"); + match post(format!("{}/swarm/peers", ipfs_api_base)) + .query("verbose", "false") + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(peers_info) = response.json::() { + if let Some(peers) = peers_info.get("Peers").and_then(|p| p.as_array()) { + println!(" Connected peers: {}", peers.len()); + // Show first few peers + for peer in peers.iter().take(2) { + if let Some(peer_id) = peer.get("Peer") { + if let Some(addr) = peer.get("Addr") { + println!( + " - Peer: {}...{}", + &peer_id.as_str().unwrap_or("")[..8], + &peer_id.as_str().unwrap_or("")[peer_id + .as_str() + .unwrap_or("") + .len() + .saturating_sub(8)..] + ); + println!(" Address: {}", addr); + } + } + } + if peers.len() > 2 { + println!(" ... and {} more peers", peers.len() - 2); + } + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + println!("\nโœ… IPFS API Demo completed!"); + println!("This example demonstrated:"); + println!(" โ€ข POST requests with no body (version, id)"); + println!(" โ€ข POST with query parameters (repo/stat, pin/ls)"); + println!(" โ€ข POST with multipart file upload (add)"); + println!(" โ€ข POST with binary responses (cat - in demonstrate_file_operations)"); + println!(" โ€ข Module-level convenience functions (swarm/peers)"); + println!(" โ€ข Different response types (JSON, binary)"); + + Ok(()) +} + +/// Demonstrates file operations with the uploaded file +fn demonstrate_file_operations( + client: &HttpClient, + api_base: &str, + file_hash: &str, +) -> Result<(), Box> { + // Example: Get file content (binary response) + println!("\n ๐Ÿ“„ GET file content (POST returning binary data):"); + match client + .post(format!("{}/cat", api_base)) + .query("arg", file_hash) + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + match response.text() { + Ok(content) => { + println!( + " File content: {}", + content.lines().next().unwrap_or("empty") + ); + println!(" Content length: {} bytes", content.len()); + } + Err(_) => { + println!(" Binary content: {} bytes", response.bytes().len()); + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + // Example: Pin the file explicitly (idempotent operation) + println!("\n ๐Ÿ“Œ PIN file (POST with path parameter):"); + match client + .post(format!("{}/pin/add", api_base)) + .query("arg", file_hash) + .query("recursive", "false") + .send() + { + Ok(response) => { + println!(" Status: {}", response.status()); + if response.is_success() { + if let Ok(pin_result) = response.json::() { + if let Some(pins) = pin_result.get("Pins").and_then(|p| p.as_array()) { + println!(" Pinned {} objects", pins.len()); + for pin in pins { + println!(" - {}", pin.as_str().unwrap_or("unknown")); + } + } + } + } + } + Err(e) => println!(" Error: {}", e), + } + + Ok(()) +} From 34c96db9b8520f8d0d883c1bb695e8c5cec15b3c Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:28:57 +1200 Subject: [PATCH 15/17] cargo fmt --all --- examples/rpc.rs | 22 ++++++++--------- src/http.rs | 10 +++++--- src/rpc.rs | 64 +++++++++++++++++++++++++++---------------------- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/examples/rpc.rs b/examples/rpc.rs index 18c30a5..2873476 100644 --- a/examples/rpc.rs +++ b/examples/rpc.rs @@ -13,31 +13,31 @@ fn main() -> Result<(), Box> { // Example 2: Echo with different data types println!("\n=== Example 2: Echo Examples ==="); - + // Echo string match client.echo("Hello, World!".to_string()) { Ok(response) => println!("Echo string: {}", response), Err(e) => println!("Echo error: {}", e), } - + // Echo number match client.echo(42) { Ok(response) => println!("Echo number: {}", response), Err(e) => println!("Echo error: {}", e), } - + // Echo complex object #[derive(Serialize, Deserialize, Debug)] struct Person { name: String, age: u32, } - + let person = Person { name: "Alice".to_string(), age: 30, }; - + match client.echo(person) { Ok(response) => println!("Echo person: {:?}", response), Err(e) => println!("Echo error: {}", e), @@ -57,24 +57,24 @@ fn main() -> Result<(), Box> { // Example 4: Generic call with custom types println!("\n=== Example 4: Generic Call ==="); - + #[derive(Serialize, Deserialize, Debug)] struct CustomRequest { message: String, count: u32, } - + #[derive(Serialize, Deserialize, Debug)] struct CustomResponse { processed: String, timestamp: u64, } - + let request = CustomRequest { message: "Test message".to_string(), count: 5, }; - + // This would fail since "custom.process" doesn't exist in our test implementation match client.call::("custom.process", Some(request)) { Ok(response) => { @@ -89,7 +89,7 @@ fn main() -> Result<(), Box> { // Example 5: Error handling println!("\n=== Example 5: Error Handling ==="); - + // Try calling a non-existent method match client.call::<(), String>("nonexistent.method", None) { Ok(response) => { @@ -102,4 +102,4 @@ fn main() -> Result<(), Box> { println!("\nAll examples completed!"); Ok(()) -} \ No newline at end of file +} diff --git a/src/http.rs b/src/http.rs index 3c6065b..8263607 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,5 +1,5 @@ +use crate::rpc::{JsonRpcResponse, RpcClient, RpcError}; use std::collections::HashMap; -use crate::rpc::{RpcClient, RpcError, JsonRpcResponse}; const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response @@ -391,10 +391,14 @@ impl HttpClient { options, }; let mut rpc_client = RpcClient::with_buffer_size(MAX_RESPONSE_SIZE); - let response: JsonRpcResponse = rpc_client.call("http.request", Some(request))?; + let response: JsonRpcResponse = + rpc_client.call("http.request", Some(request))?; if let Some(error) = response.error { - return Err(HttpError::RequestFailed(format!("RPC error: {} (code: {})", error.message, error.code))); + return Err(HttpError::RequestFailed(format!( + "RPC error: {} (code: {})", + error.message, error.code + ))); } let http_result = response.result.ok_or(HttpError::EmptyResponse)?; diff --git a/src/rpc.rs b/src/rpc.rs index e4685d0..d106faf 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -86,44 +86,44 @@ pub struct JsonRpcError { } /// Unified RPC client for calling host functions -/// +/// /// # Example Usage -/// +/// /// ```rust /// use blockless_sdk::rpc::RpcClient; /// use serde::{Serialize, Deserialize}; -/// +/// /// #[derive(Serialize, Deserialize)] /// struct HttpRequest { /// url: String, /// method: String, /// } -/// +/// /// #[derive(Serialize, Deserialize)] /// struct HttpResponse { /// status: u16, /// body: String, /// } -/// +/// /// // Create client with default 4KB buffer /// let mut client = RpcClient::new(); -/// +/// /// // Create client with custom buffer size (e.g., 10MB for HTTP responses) /// let mut client = RpcClient::with_buffer_size(10 * 1024 * 1024); -/// +/// /// // Type-safe method call /// let request = HttpRequest { /// url: "https://api.example.com".to_string(), /// method: "GET".to_string(), /// }; -/// +/// /// let response: JsonRpcResponse = client.call("http.request", Some(request))?; -/// +/// /// // Convenience methods /// let pong = client.ping()?; /// let echo_result = client.echo("Hello World!")?; /// let version = client.version()?; -/// +/// /// // Modify buffer size after creation /// client.set_buffer_size(1024 * 1024); // 1MB buffer /// ``` @@ -136,33 +136,36 @@ impl RpcClient { pub fn new() -> Self { Self::with_buffer_size(4096) // Default 4KB buffer } - + pub fn with_buffer_size(buffer_size: usize) -> Self { - Self { + Self { next_id: 1, buffer_size, } } - + pub fn set_buffer_size(&mut self, buffer_size: usize) { self.buffer_size = buffer_size; } - + pub fn buffer_size(&self) -> usize { self.buffer_size } - - pub fn call(&mut self, method: &str, params: Option

) -> Result, RpcError> { + + pub fn call( + &mut self, + method: &str, + params: Option

, + ) -> Result, RpcError> { let request = JsonRpcRequest { jsonrpc: "2.0".to_string(), method: method.to_string(), params, id: self.next_id, }; - + self.next_id += 1; - let request_bytes = serde_json::to_vec(&request) - .map_err(|_| RpcError::InvalidJson)?; + let request_bytes = serde_json::to_vec(&request).map_err(|_| RpcError::InvalidJson)?; let mut response_buffer = vec![0u8; self.buffer_size]; let mut bytes_written = 0u32; let result = unsafe { @@ -185,25 +188,28 @@ impl RpcClient { }; } response_buffer.truncate(bytes_written as usize); - serde_json::from_slice(&response_buffer) - .map_err(|_| RpcError::InvalidJson) + serde_json::from_slice(&response_buffer).map_err(|_| RpcError::InvalidJson) } - + /// Convenience method for ping pub fn ping(&mut self) -> Result { let response: JsonRpcResponse = self.call("ping", None::<()>)?; response.result.ok_or(RpcError::InternalError) } - + /// Convenience method for echo - pub fn echo(&mut self, data: T) -> Result { + pub fn echo( + &mut self, + data: T, + ) -> Result { let response: JsonRpcResponse = self.call("echo", Some(data))?; response.result.ok_or(RpcError::InternalError) } - + /// Convenience method for getting version pub fn version(&mut self) -> Result, RpcError> { - let response: JsonRpcResponse> = self.call("version", None::<()>)?; + let response: JsonRpcResponse> = + self.call("version", None::<()>)?; response.result.ok_or(RpcError::InternalError) } } @@ -220,18 +226,18 @@ mod tests { params: None, id: 1, }; - + let json_str = serde_json::to_string(&request).unwrap(); assert!(json_str.contains("\"jsonrpc\":\"2.0\"")); assert!(json_str.contains("\"method\":\"ping\"")); assert!(json_str.contains("\"id\":1")); } - + #[test] fn test_rpc_response_deserialization() { let json_str = r#"{"jsonrpc":"2.0","result":"pong","id":1}"#; let response: JsonRpcResponse = serde_json::from_str(json_str).unwrap(); - + assert_eq!(response.jsonrpc, "2.0"); assert_eq!(response.result, Some("pong".to_string())); assert_eq!(response.id, 1); From 189f80e97a025b353c1a80839ccb4a4233a930a5 Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:34:28 +1200 Subject: [PATCH 16/17] fixed unit test; added default impl --- src/rpc.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/rpc.rs b/src/rpc.rs index d106faf..b8ba5f5 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -18,8 +18,6 @@ extern "C" { #[cfg(feature = "mock-ffi")] #[allow(unused_variables)] mod mock_ffi { - use super::*; - pub unsafe fn rpc_call( _request_ptr: *const u8, _request_len: u32, @@ -32,6 +30,9 @@ mod mock_ffi { } } +#[cfg(feature = "mock-ffi")] +use mock_ffi::*; + #[derive(Debug, Clone)] pub enum RpcError { InvalidJson, @@ -132,9 +133,15 @@ pub struct RpcClient { buffer_size: usize, } +impl Default for RpcClient { + fn default() -> Self { + Self::with_buffer_size(4096) // Default 4KB buffer + } +} + impl RpcClient { pub fn new() -> Self { - Self::with_buffer_size(4096) // Default 4KB buffer + Self::default() } pub fn with_buffer_size(buffer_size: usize) -> Self { From f77efbb4257bcf830eba6576a0ab0943af4f932e Mon Sep 17 00:00:00 2001 From: z Date: Mon, 14 Jul 2025 14:47:39 +1200 Subject: [PATCH 17/17] removed doc-test example --- src/rpc.rs | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/src/rpc.rs b/src/rpc.rs index b8ba5f5..a4a3a04 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -87,47 +87,6 @@ pub struct JsonRpcError { } /// Unified RPC client for calling host functions -/// -/// # Example Usage -/// -/// ```rust -/// use blockless_sdk::rpc::RpcClient; -/// use serde::{Serialize, Deserialize}; -/// -/// #[derive(Serialize, Deserialize)] -/// struct HttpRequest { -/// url: String, -/// method: String, -/// } -/// -/// #[derive(Serialize, Deserialize)] -/// struct HttpResponse { -/// status: u16, -/// body: String, -/// } -/// -/// // Create client with default 4KB buffer -/// let mut client = RpcClient::new(); -/// -/// // Create client with custom buffer size (e.g., 10MB for HTTP responses) -/// let mut client = RpcClient::with_buffer_size(10 * 1024 * 1024); -/// -/// // Type-safe method call -/// let request = HttpRequest { -/// url: "https://api.example.com".to_string(), -/// method: "GET".to_string(), -/// }; -/// -/// let response: JsonRpcResponse = client.call("http.request", Some(request))?; -/// -/// // Convenience methods -/// let pong = client.ping()?; -/// let echo_result = client.echo("Hello World!")?; -/// let version = client.version()?; -/// -/// // Modify buffer size after creation -/// client.set_buffer_size(1024 * 1024); // 1MB buffer -/// ``` pub struct RpcClient { next_id: u32, buffer_size: usize,