diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab7eb4e..5798308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - name: 'Checkout' uses: actions/checkout@v4 - name: 'Run unit tests' - run: cargo test --locked --workspace --exclude http_canister --exclude json_rpc_canister + run: cargo test --locked --workspace --exclude http_canister --exclude json_rpc_canister --exclude multi_canister integration-tests: runs-on: ubuntu-latest @@ -65,21 +65,15 @@ jobs: - name: 'Checkout' uses: actions/checkout@v4 - - name: 'Build http_canister' + - name: 'Build example canisters' run: | - cargo build -p http_canister --target wasm32-unknown-unknown --release --locked + cargo build -p http_canister -p json_rpc_canister -p multi_canister --target wasm32-unknown-unknown --release --locked - - name: 'Set HTTP_CANISTER_WASM_PATH for load_wasm' + - name: 'Set WASM paths for load_wasm' run: | echo "HTTP_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/http_canister.wasm" >> "$GITHUB_ENV" - - - name: 'Build json_rpc_canister' - run: | - cargo build -p json_rpc_canister --target wasm32-unknown-unknown --release --locked - - - name: 'Set JSON_RPC_CANISTER_WASM_PATH for load_wasm' - run: | echo "JSON_RPC_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/json_rpc_canister.wasm" >> "$GITHUB_ENV" + echo "MULTI_CANISTER_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/release/multi_canister.wasm" >> "$GITHUB_ENV" - name: 'Install PocketIC server' uses: dfinity/pocketic@main diff --git a/Cargo.lock b/Cargo.lock index 9c90f78..9ad7153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1744,6 +1744,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multi_canister" +version = "1.0.0" +dependencies = [ + "candid", + "canhttp", + "http", + "ic-cdk", + "ic-management-canister-types", + "ic-test-utilities-load-wasm", + "pocket-ic", + "serde", + "serde_json", + "tower", + "uuid", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3213,6 +3230,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 78ca9ec..0d74f25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "canhttp", "examples/http_canister", "examples/json_rpc_canister", + "examples/multi_canister", "ic-agent-canister-runtime", "ic-canister-runtime", "ic-mock-http-canister-runtime", @@ -49,6 +50,7 @@ tokio = "1.44.1" tower = "0.5.2" tower-layer = "0.3.3" url = "2.5.7" +uuid = "1.16.0" [profile.release] debug = false diff --git a/README.md b/README.md index 739935a..e6268b7 100644 --- a/README.md +++ b/README.md @@ -38,15 +38,15 @@ Complete examples are available [here](examples) and see also the [Rust document ### Feature `http` -Offers middleware that transforms a low-level service that uses Candid types into one that uses types from the [http](https://crates.io/crates/http) crate. +Offers middleware that transforms a low-level service that uses Candid types into one that uses types from the [http](https://crates.io/crates/http) crate. See the [`http_canister`](examples/http_canister) for a complete example. ### Feature `json` -Offers middleware that transforms a low-level service that transmits bytes into one that transmits JSON payloads. +Offers middleware that transforms a low-level service that transmits bytes into one that transmits JSON payloads. See the [`json_rpc_canister`](examples/json_rpc_canister) for a complete example. ### Feature `multi` -Make multiple calls in parallel and handle their multiple results. +Make multiple calls in parallel and handle their multiple results. See the [`multi_canister`](examples/multi_canister) for a complete example. ## License diff --git a/examples/multi_canister/Cargo.toml b/examples/multi_canister/Cargo.toml new file mode 100644 index 0000000..b01316c --- /dev/null +++ b/examples/multi_canister/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "multi_canister" +version = "1.0.0" +edition.workspace = true + +[[bin]] +name = "multi_canister" +path = "src/main.rs" + +[dependencies] +candid = { workspace = true } +canhttp = { path = "../../canhttp", features = ["http", "json", "multi"] } +http = { workspace = true } +ic-cdk = { workspace = true } +serde_json = { workspace = true } +tower = { workspace = true } + +[dev-dependencies] +ic-management-canister-types = { workspace = true } +ic-test-utilities-load-wasm = { workspace = true } +pocket-ic = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } diff --git a/examples/multi_canister/src/main.rs b/examples/multi_canister/src/main.rs new file mode 100644 index 0000000..eac51de --- /dev/null +++ b/examples/multi_canister/src/main.rs @@ -0,0 +1,78 @@ +//! Example of a canister using `canhttp` to issue multiple requests in parallel. + +use canhttp::http::json::JsonResponseConverter; +use canhttp::http::HttpRequest; +use canhttp::multi::parallel_call; +use canhttp::{ + cycles::{ChargeMyself, CyclesAccountingServiceBuilder}, + http::HttpConversionLayer, + observability::ObservabilityLayer, + Client, ConvertServiceBuilder, +}; +use ic_cdk::update; +use std::iter; +use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; + +/// Make parallel HTTP requests. +#[update] +pub async fn make_parallel_http_requests() -> Vec { + let request = http::Request::get(format!("{}/uuid", httpbin_base_url())) + .body(vec![]) + .unwrap(); + + let mut client = http_client(); + client.ready().await.expect("Client should be ready"); + + let (_client, results) = parallel_call(client, iter::repeat_n(request, 5).enumerate()).await; + let (results, errors) = results.into_inner(); + if !errors.is_empty() { + panic!( + "Requests should all succeed but received {} errors: {:?}", + errors.len(), + errors + ); + } + + results + .into_values() + .map(|response| { + assert_eq!(response.status(), http::StatusCode::OK); + response.body()["uuid"] + .as_str() + .expect("Expected UUID in response") + .to_string() + }) + .collect() +} + +fn http_client( +) -> impl Service, Error = BoxError> { + ServiceBuilder::new() + // Print request, response and errors to the console + .layer( + ObservabilityLayer::new() + .on_request(|request: &http::Request>| ic_cdk::println!("{request:?}")) + .on_response(|_, response: &http::Response| { + ic_cdk::println!("{response:?}"); + }) + .on_error(|_, error: &BoxError| { + ic_cdk::println!("Error {error:?}"); + }), + ) + // Parse the response as JSON + .convert_response(JsonResponseConverter::::new()) + // Convert the request and responses to types from the `http` crate + .layer(HttpConversionLayer) + // Use cycles from the canister to pay for HTTPs outcalls + .cycles_accounting(ChargeMyself::default()) + // The actual client + .service(Client::new_with_box_error()) +} + +fn httpbin_base_url() -> String { + option_env!("HTTPBIN_URL") + .unwrap_or_else(|| "https://httpbin.org") + .to_string() +} + +fn main() {} diff --git a/examples/multi_canister/tests/tests.rs b/examples/multi_canister/tests/tests.rs new file mode 100644 index 0000000..f4e5410 --- /dev/null +++ b/examples/multi_canister/tests/tests.rs @@ -0,0 +1,115 @@ +use candid::{decode_args, encode_args, utils::ArgumentEncoder, CandidType, Encode, Principal}; +use ic_management_canister_types::{CanisterId, CanisterSettings}; +use pocket_ic::{PocketIc, PocketIcBuilder}; +use serde::de::DeserializeOwned; +use std::{env::var, path::PathBuf, sync::Arc}; +use uuid::Uuid; + +#[test] +fn should_make_parallel_http_requests() { + let setup = Setup::default(); + let http_canister = setup.http_canister(); + + let http_request_results = http_canister.update_call::<_, Vec>( + Principal::anonymous(), + "make_parallel_http_requests", + (), + ); + + for uuid in http_request_results { + assert!(Uuid::parse_str(uuid.as_str()).is_ok()); + } +} + +pub struct Setup { + env: Arc, + http_canister_id: CanisterId, +} +impl Setup { + pub const DEFAULT_CONTROLLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); + + pub fn new() -> Self { + let env = PocketIcBuilder::new() + .with_nns_subnet() //make_live requires NNS subnet. + .with_fiduciary_subnet() + .build(); + + let canister_id = env.create_canister_with_settings( + None, + Some(CanisterSettings { + controllers: Some(vec![Self::DEFAULT_CONTROLLER]), + ..CanisterSettings::default() + }), + ); + env.add_cycles(canister_id, u64::MAX as u128); + + env.install_canister( + canister_id, + multi_canister_wasm(), + Encode!().unwrap(), + Some(Self::DEFAULT_CONTROLLER), + ); + + let mut env = env; + let _endpoint = env.make_live(None); + + Self { + env: Arc::new(env), + http_canister_id: canister_id, + } + } + + fn http_canister(&self) -> Canister { + Canister { + env: self.env.clone(), + id: self.http_canister_id, + } + } +} + +impl Default for Setup { + fn default() -> Self { + Self::new() + } +} + +pub struct Canister { + env: Arc, + id: CanisterId, +} + +impl Canister { + pub fn update_call(&self, sender: Principal, method: &str, args: In) -> Out + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + let message_id = self + .env + .submit_call( + self.id, + sender, + method, + encode_args(args).unwrap_or_else(|e| { + panic!("Failed to encode arguments for method {method}: {e}") + }), + ) + .unwrap_or_else(|e| panic!("Failed to call method {method}: {e}")); + let response_bytes = self + .env + .await_call_no_ticks(message_id) + .unwrap_or_else(|e| panic!("Failed to await call for method {method}: {e}")); + let (res,) = decode_args(&response_bytes).unwrap_or_else(|e| { + panic!("Failed to decode canister response for method {method}: {e}") + }); + res + } +} + +fn multi_canister_wasm() -> Vec { + ic_test_utilities_load_wasm::load_wasm( + PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("."), + "multi_canister", + &[], + ) +}