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 } 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 | ✅ | ❌ | 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()); 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); - }); -} 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(()) +} diff --git a/examples/rpc.rs b/examples/rpc.rs new file mode 100644 index 0000000..2873476 --- /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(()) +} diff --git a/src/http.rs b/src/http.rs index 6543378..8263607 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,217 +1,818 @@ -use crate::error::HttpErrorKind; -use json::JsonValue; -use std::{cmp::Ordering, collections::BTreeMap}; +use crate::rpc::{JsonRpcResponse, RpcClient, RpcError}; +use std::collections::HashMap; -#[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, - url_len: u32, - opts: *const u8, - opts_len: u32, - fd: *mut u32, - status: *mut u32, - ) -> u32; +const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response - #[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; +// RPC request/response structures for HTTP +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpRpcRequest { + pub url: String, + pub options: HttpOptions, +} - #[link_name = "http_read_body"] - pub(crate) fn http_read_body(handle: u32, buf: *mut u8, buf_len: u32, num: *mut u32) -> u32; +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum HttpBody { + Text(String), + Binary(Vec), + Form(HashMap), + Multipart(Vec), +} - #[link_name = "http_close"] - pub(crate) fn http_close(handle: u32) -> u32; +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +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>, +} + +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, + } + } } -#[cfg(feature = "mock-ffi")] -#[allow(unused_variables)] -mod mock_ffi { +impl HttpOptions { + pub fn new() -> Self { + Self::default() + } + + 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 fn headers(mut self, headers: HashMap) -> Self { + self.headers = Some(headers); + self + } + + 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 unsafe fn http_open( - _url: *const u8, - _url_len: u32, - _opts: *const u8, - _opts_len: u32, - fd: *mut u32, - status: *mut u32, - ) -> u32 { - unimplemented!() + 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 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 timeout(mut self, timeout_ms: u32) -> Self { + self.timeout = Some(timeout_ms); + self } - pub unsafe fn http_read_body(_handle: u32, buf: *mut u8, buf_len: u32, num: *mut u32) -> u32 { - unimplemented!() + 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 unsafe fn http_close(_handle: u32) -> u32 { - unimplemented!() + 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 + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum MultipartValue { + Text(String), + Binary { + data: Vec, + filename: Option, + content_type: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MultipartField { + pub name: String, + pub value: MultipartValue, } -#[cfg(feature = "mock-ffi")] -use mock_ffi::*; +impl MultipartField { + pub fn text, V: Into>(name: N, value: V) -> Self { + Self { + name: name.into(), + value: MultipartValue::Text(value.into()), + } + } -type Handle = u32; -type ExitCode = u32; + 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 struct BlocklessHttp { - inner: Handle, - code: ExitCode, + 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, + }, + } + } } -pub struct HttpOptions { - pub method: String, - pub connect_timeout: u32, - pub read_timeout: u32, - pub body: Option, - pub headers: Option>, +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + pub body: Vec, + pub url: String, } -impl HttpOptions { - pub fn new(method: &str, connect_timeout: u32, read_timeout: u32) -> Self { - HttpOptions { - method: method.into(), - connect_timeout, - read_timeout, +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, +} + +#[derive(Debug, Clone)] +pub enum HttpError { + InvalidUrl, + SerializationError, + JsonParseError, + Utf8Error, + EmptyResponse, + RequestFailed(String), + NetworkError, + Timeout, + RpcError(RpcError), + Unknown(u32), +} + +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::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 { + default_headers: Option>, + timeout: Option, +} + +impl Default for HttpClient { + fn default() -> Self { + Self::new() + } +} + +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 { + 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()) + }, }; - if rs != 0 { - return Err(HttpErrorKind::from(rs)); + + self.make_request(&builder.url, options) + } + + fn make_request(&self, url: &str, options: HttpOptions) -> Result { + if url.is_empty() { + return Err(HttpError::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 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 let Some(error) = response.error { + return Err(HttpError::RequestFailed(format!( + "RPC error: {} (code: {})", + error.message, error.code + ))); } - Ok(Self { - inner: fd, - code: status, - }) + let http_result = response.result.ok_or(HttpError::EmptyResponse)?; + + if !http_result.success { + let error_msg = http_result + .error + .unwrap_or_else(|| "Unknown error".to_string()); + return Err(HttpError::RequestFailed(error_msg)); + } + + http_result.data.ok_or(HttpError::EmptyResponse) + } +} + +pub struct HttpClientBuilder { + default_headers: Option>, + timeout: Option, +} + +impl Default for HttpClientBuilder { + fn default() -> Self { + Self::new() } +} - pub fn get_code(&self) -> ExitCode { - self.code +impl HttpClientBuilder { + pub fn new() -> Self { + Self { + default_headers: None, + timeout: Some(30000), + } } - 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)); - } + pub fn default_headers(mut self, headers: HashMap) -> Self { + self.default_headers = Some(headers); + self + } - match num.cmp(&0) { - Ordering::Greater => vec.extend_from_slice(&buf[0..num as _]), - _ => break, - } + 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, } - 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, + } +} +pub struct RequestBuilder { + client: HttpClient, + method: String, + url: String, + headers: HashMap, + query_params: HashMap, + body: Option, + timeout: Option, +} + +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 +// ==================== + +pub fn build_url_with_params(base_url: &str, params: &HashMap) -> String { + if params.is_empty() { + return base_url.to_string(); + } + 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() } - String::from_utf8(vec).map_err(|_| HttpErrorKind::Utf8Error) - } + 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); - pub fn close(self) { - unsafe { - http_close(self.inner); + 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 } } +} + +// ==================== +// Module-level Convenience Functions +// ==================== - 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 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"), } - Ok(num) } -} -impl Drop for BlocklessHttp { - fn drop(&mut self) { - unsafe { - http_close(self.inner); + #[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+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(); + 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+chars=test+%26+encode")); + } + + #[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"); + } } diff --git a/src/lib.rs b/src/lib.rs index b60c611..7328e1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,16 @@ mod bless_crawl; mod cgi; mod error; -mod http; mod llm; mod memory; mod socket; +pub mod http; +pub mod rpc; + pub use bless_crawl::*; pub use cgi::*; pub use error::*; -pub use http::*; pub use llm::*; pub use memory::*; pub use socket::*; diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..a4a3a04 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,212 @@ +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 { + 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 + } +} + +#[cfg(feature = "mock-ffi")] +use mock_ffi::*; + +#[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 +pub struct RpcClient { + next_id: u32, + 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::default() + } + + 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()); + } +}