Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions examples/multi_canister/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
78 changes: 78 additions & 0 deletions examples/multi_canister/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<HttpRequest, Response = http::Response<serde_json::Value>, Error = BoxError> {
ServiceBuilder::new()
// Print request, response and errors to the console
.layer(
ObservabilityLayer::new()
.on_request(|request: &http::Request<Vec<u8>>| ic_cdk::println!("{request:?}"))
.on_response(|_, response: &http::Response<serde_json::Value>| {
ic_cdk::println!("{response:?}");
})
.on_error(|_, error: &BoxError| {
ic_cdk::println!("Error {error:?}");
}),
)
// Parse the response as JSON
.convert_response(JsonResponseConverter::<serde_json::Value>::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() {}
115 changes: 115 additions & 0 deletions examples/multi_canister/tests/tests.rs
Original file line number Diff line number Diff line change
@@ -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<String>>(
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<PocketIc>,
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<PocketIc>,
id: CanisterId,
}

impl Canister {
pub fn update_call<In, Out>(&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<u8> {
ic_test_utilities_load_wasm::load_wasm(
PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("."),
"multi_canister",
&[],
)
}